Skip to content

fix(ui): pinned critical as a block, and a group that names its attack type - #90

Merged
elluff merged 2 commits into
mainfrom
feat/grouped-alerts-polish
Aug 19, 2026
Merged

fix(ui): pinned critical as a block, and a group that names its attack type#90
elluff merged 2 commits into
mainfrom
feat/grouped-alerts-polish

Conversation

@elluff

@elluff elluff commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What & why

Follow-up review of the grouped alerts view shipped in #87 / #89, now live on mainnet. Three things did not hold up in use, and fixing the third needed the API to say something it was not saying.

The pinned latest-critical row read as an accident. It was distinguished only by a red left border plus a red BOTTOM border nobody intended: border-severity-critical-foreground/60 sets the colour on every edge, and TableRow already draws a bottom border, so the row inherited a red underline as a side effect. The strong accent is now scoped to the left edge, the row carries a faint critical tint closed by a deliberate edge, and a section strip sits above it.

The "latest critical" label moved out of the transaction-hash cell. Sitting beside the hash it read as an attribute of that transaction, when being pinned is a property of the row's PLACEMENT. It is now a strip above the row, saying what the pin means: kept in view regardless of the sort below. A spacer row separates the block from the sorted list.

A group row's generic file icon is gone, and the contract address gained a copy button instead. The cell shows a registry label or a truncated address, so the full bech32 value could not be selected off the screen. It stops propagation, or copying would also expand the group.

The Attack Type column held the alert count, promising a type and delivering a number. It now names the class of the group's WORST alert, the same alert the severity badge on that line describes, plus "+N more" when the group holds other kinds. The count moved under the contract name, where it describes the group.

That last one needed two new aggregates, and both carry a hazard this repo has been bitten by:

argMax((risk_band, max_class), max_score) AS worst_pair,
arraySort(groupUniqArray(max_class)) AS classes,

One argMax over a TUPLE, not two argMax calls sharing an ordering: ClickHouse breaks a tie on equal max_score arbitrarily, and two aggregates can resolve it on different rows, which would put a severity badge and an attack type belonging to two different alerts on one line. That is not theoretical. In one live query, two contracts holding the same two scores resolved to different winners, ('Critical','circular') and ('High','phishing'), each pair internally coherent. arraySort because groupUniqArray has no stable order and the tooltip would otherwise reshuffle between identical refreshes. Both aliased away from the source column names, because on 26.x an aggregate aliased to a column a sibling aggregate reads returns Code 184.

The contract_anomaly reconciliation keeps pace: a verdict that takes over a group renames its attack type as well as its band, and its synthetic class is unioned into the set SQL cannot see. The union never subtracts, since proving no sibling row still carries the stored class would need per-class counts rather than a set; over-stating by one is the safe direction for a "+N other kinds" hint, and it is documented at the merge. The API re-sorts the set at the boundary because that merge appends after SQL has sorted.

Verified on a real server, not just against a fake client

The grouped aggregate now has coverage in backend/tests/live_db/, the tier that exists because mocked clients let two ClickHouse 26.x regressions ship with green tests. Run against 26.1.3.52, the production version: the aggregate executes (no Code 184 from the two aggregates reading max_class), a group counts and names its worst alert, the class list comes back sorted, and a tie returns a coherent pair.

One honest limit: that tie test still passes with the OLD two-argMax form, because on a small dataset ClickHouse happens to pick the same row for both aggregates. It documents the invariant. What deterministically catches the old form is the query-text test, and reverting the SQL proves it fails.

Checklist

  • pytest tests/ green (1282), plus 9 live-DB ClickHouse tests against 26.1.3.52
  • pnpm lint && pnpm build green, pnpm test 137 passed
  • uv run pytest -q green (sidecar untouched)
  • No unexplained numeric literals
  • Docs updated (README's grouped-endpoint row documents the two new response fields)

The five new Tailwind utilities were checked against the built CSS, because a class Tailwind cannot generate fails silently rather than erroring. That is also how the hover fix was confirmed: TableRow ships hover:bg-muted/40 and tailwind-merge does not drop it for a plain bg-*, so without restating the hover tint the pinned block came apart at the moment the analyst pointed at it.

elluff added 2 commits August 19, 2026 17:46
…ttack type

Three complaints from reviewing the shipped grouped alerts view.

The pinned latest-critical row was distinguished only by a red left border, and
by an accidental red BOTTOM border: `border-severity-critical-foreground/60` sets
the colour on every edge, and TableRow already draws a bottom border, so the row
picked up a red underline nobody asked for. The accent is now scoped to the left
edge, the row carries a faint critical tint, and a section strip sits above it
carrying the label. The label moved out of the transaction-hash cell for the
reason it looked misplaced there: being pinned is a property of the row's
PLACEMENT, and a badge beside the hash reads as an attribute of that transaction.
A spacer row separates the block from the sorted list below.

The group row's file icon was generic and said nothing, so it is gone. The
contract address gains a copy button instead: the cell shows a registry label or
a truncated address, so the full bech32 value could not be selected off the
screen. It stops propagation, or copying would also expand the group.

The Attack Type column held the alert count on a group row, promising a type and
delivering a number. It now names the class of the group's WORST alert, which is
the same alert the severity badge on that line describes, plus "+N more" when the
group holds other kinds, tooltipped with their names. The count moves under the
contract name, where it describes the group. Two aggregates back this:
`argMax(max_class, max_score) AS worst_class` and `groupUniqArray(max_class)`,
both aliased away from the source column name because on ClickHouse 26.x an
aggregate aliased to a column a sibling aggregate reads returns Code 184.

The contract_anomaly reconciliation keeps both in step: a verdict that takes over
a group renames its attack type as well as its band, and its synthetic class is
unioned into the class set that SQL cannot see. The union never subtracts, since
proving no sibling row still carries the stored class would need per-class counts
rather than a set; over-stating by one is the safe direction for a "+N other
kinds" hint, and it is documented at the merge.

Gates: 1283 backend (up 7), recall gate 554 unchanged, 136 frontend (up 5), ruff
and mypy clean on both trees, eslint and build clean. The five new Tailwind
utilities were checked against the built CSS, because a class Tailwind cannot
generate fails silently rather than erroring.
…ate live

Seven review findings on the previous commit. All seven were real.

The band and the class came from two independent argMax calls sharing an
ordering, and the docstring claimed they agreed "by construction". They did not:
ClickHouse breaks a tie on equal max_score arbitrarily, and two aggregates can
resolve it on different rows, which is precisely the invariant the change set
out to establish. One argMax over the tuple (risk_band, max_class) now returns
one row's pair. Live evidence that ties really are arbitrary: two contracts
holding the same two scores resolved to different winners in the same query,
('Critical','circular') and ('High','phishing'), each pair internally coherent.

groupUniqArray has no stable order, so the "+N more" tooltip could reshuffle
between identical refreshes. arraySort fixes the SQL path, and the API boundary
re-sorts because the contract_anomaly merge APPENDS its synthetic class after
SQL has sorted.

The aggregate had never run on a real server: the test asserted query text
against a fake client, and tests/live_db/ had no grouping coverage. That is the
tier which exists because mocked clients let two 26.x regressions ship, and this
query reads max_class from two aggregates at once, the exact shape that returns
Code 184 on an alias collision. It is now covered there and verified against
ClickHouse 26.1.3.52, the production version.

Honest limit of that live test: with two independent argMax calls it still
passes, because on a small dataset ClickHouse happens to pick the same row for
both. It documents the invariant; what deterministically catches the old form is
the query-text test, and reverting proves it fails.

The pinned row lost its tint on hover. TableRow ships hover:bg-muted/40 and
tailwind-merge does not drop it for a plain bg-*, so the row turned grey while
the strip above stayed red: the block came apart at the moment the analyst
pointed at it. hover:bg-severity-critical/30 restated, and checked in the built
CSS.

The un-clusterable marker sat behind an early return on the attack type. It is
unreachable today, since such a group has worst_class = contract_anomaly by
construction, but "do not trust this model" is the signal that must survive
longest and it does not depend on the class. It now renders outside that guard,
with a test that pins the pairing the current data cannot produce.

The "+N more" tooltip spoke in the absolute present while classes comes from the
same filtered GROUP BY as every other number on the row, so it now says "matching
the current filter".

Formatting: the four test lines added by hand were not prettier-clean. Fixed for
those files only; the 19 other files that fail prettier --check are pre-existing
drift on main and are left alone.

Gates: 1282 backend (one test fewer, two merged into one), recall gate 554
unchanged, 9 live-DB ClickHouse tests, 137 frontend, ruff and mypy clean, eslint
and build clean.
@elluff
elluff merged commit f6899e2 into main Aug 19, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant