diff --git a/frontend/src/components/alerts/alert-rows.tsx b/frontend/src/components/alerts/alert-rows.tsx index 3757568..b347834 100644 --- a/frontend/src/components/alerts/alert-rows.tsx +++ b/frontend/src/components/alerts/alert-rows.tsx @@ -38,9 +38,13 @@ import { */ export const ALERT_COLUMN_COUNT = 5; -/** Truncation for a bech32 contract address shown as a group's fallback name. */ -const CONTRACT_HEAD = 14; -const CONTRACT_TAIL = 6; +/** + * Truncation for a bech32 contract address shown in place of a registry name. + * Exported because the contract-scoped view's chip names the same addresses and + * has to truncate them the same way. + */ +export const CONTRACT_HEAD = 14; +export const CONTRACT_TAIL = 6; /** * The pinned latest-critical row: tint, and an outline on all four sides. @@ -59,6 +63,22 @@ const CONTRACT_TAIL = 6; const PINNED_ROW = "bg-severity-critical/25 border-b-0 outline-1 -outline-offset-1 outline-severity-critical-foreground/60"; +/** + * The rule that runs down a child row's gutter, marking it as nested. + * + * Drawn as a pseudo-element spanning the cell's full height, so consecutive + * children abut into ONE continuous line rather than a dashed column of + * segments. It sits at 1.5rem, the centre of the parent's chevron, so the line + * visibly descends from the control that opened the group: the structure is the + * device, not decoration on top of it. + * + * A rule and not a bigger indent, because the columns have to stay columns. The + * date and severity of a child must line up with every other row's or the table + * stops being readable across. + */ +const CHILD_RULE = + "relative before:absolute before:inset-y-0 before:left-6 before:w-px before:bg-border"; + /** * Copies an identifier the table can only show truncated. * @@ -156,26 +176,37 @@ export function AlertRow({ indented = false, pinned = false, contractLabel, + contractNamed = false, }: { alert: RiskAlert; onOpen: (slug: string) => void; - /** Nested inside an expanded group, so the chevron cell is a spacer. */ + /** Nested inside an expanded group: rule in the gutter, content indented. */ indented?: boolean; /** The pinned latest-critical row: tinted, accented, and marked as pinned. */ pinned?: boolean; /** Registry display name for this alert's contract, when one is known. */ contractLabel?: string; + /** The surrounding view already names this contract, so the row must not. */ + contractNamed?: boolean; }) { // Which contract this alert implicates, shown only where nothing else says it. - // Inside an expanded group the contract IS the heading above these rows, and a - // flat row is flat precisely because its class names no contract, so the only - // row this line has anything to add to is the pinned one. - const contract = !indented ? alert.contractAddress : undefined; + // Inside an expanded group the contract IS the heading above these rows, in a + // contract-scoped table the chip names it once, and a flat row is flat + // precisely because its class names no contract. What is left is the pinned + // row, which is the only one this line has anything to add to. + const contract = + indented || contractNamed ? undefined : alert.contractAddress; return ( onOpen(alert.slug)} className={cn( "cursor-pointer", + // A divider running the table's whole width is what says "top-level + // row". A child keeps its divider but starts it after the gutter, so + // the rows still separate, the last one still closes the group, and + // the undrawn segment is exactly where the rule runs. + indented && + "[&>td:not(:first-child)]:border-border/60 border-b-0 [&>td:not(:first-child)]:border-b", // The hover tint has to be restated: TableRow ships // `hover:bg-muted/40`, and tailwind-merge does not drop it for a // plain `bg-*`, so without this the row turns grey the moment the @@ -185,13 +216,16 @@ export function AlertRow({ > {/* The gutter is where a group row shows its chevron, i.e. the column an operator already scans for what a row IS rather than what it holds. The - pin belongs there for the same reason. */} - + pin belongs there for the same reason, and so does a child's rule. */} + {pinned && ( )} - + {/* The indent goes on the CONTENT cell. It used to sit on the gutter cell, + which is empty on a child row, so it moved nothing: the reason these + rows read as siblings of the contract that holds them. */} +
{/* Mono and uppercase belong to the hash, not to the cell: the marker beside it is prose and would inherit both. */} @@ -292,10 +326,27 @@ export function ContractGroupRow({ return ( - + @@ -349,13 +400,30 @@ export function PinnedCriticalSpacerRow() { ); } -/** Full-width message row inside an expanded group (loading / error / empty). */ +/** + * Full-width message row inside an expanded group (loading, error, empty, or the + * count of alerts beyond the expansion limit). + * + * Carries the same gutter rule as the child rows, because it speaks about THIS + * group. Outside the rule it read as a note from the table itself, which is how + * a sentence like "134 older alerts are not listed here" ends up looking as + * though it were about the whole list. + */ export function GroupMessageRow({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/frontend/src/components/alerts/contract-group-alerts.tsx b/frontend/src/components/alerts/contract-group-alerts.tsx index f2f5cf7..ada89b8 100644 --- a/frontend/src/components/alerts/contract-group-alerts.tsx +++ b/frontend/src/components/alerts/contract-group-alerts.tsx @@ -24,6 +24,7 @@ export function ContractGroupAlerts({ alertCount, filters, onOpen, + onSeeAll, }: { contract: string; /** Server-side total for the group, used to report anything beyond the limit. */ @@ -31,6 +32,8 @@ export function ContractGroupAlerts({ /** The table's active filters, so expansion agrees with the group's count. */ filters: Omit; onOpen: (slug: string) => void; + /** Scopes the whole table to this contract, which is how the surplus is read. */ + onSeeAll: (contract: string) => void; }) { const { data, isPending, isError, error } = useRiskAlerts({ ...filters, @@ -61,9 +64,23 @@ export function ContractGroupAlerts({ ))} {hidden > 0 && ( - {`Showing the ${rows.length} most recent of ${alertCount}. ${hidden} older ${ - hidden === 1 ? "alert is" : "alerts are" - } not listed here.`} + {`Showing the ${rows.length} most recent of ${alertCount}. `} + {/* The surplus used to be stated and then unreachable: one expansion + fetches one page and has no pager of its own. Scoping the table to + this contract hands the surplus to the pager that already exists, + and puts the view in the URL so it can be shared. */} + )} diff --git a/frontend/src/pages/AttacksPage.test.tsx b/frontend/src/pages/AttacksPage.test.tsx index 63d9e33..d4c44b1 100644 --- a/frontend/src/pages/AttacksPage.test.tsx +++ b/frontend/src/pages/AttacksPage.test.tsx @@ -76,6 +76,19 @@ vi.mock("@/lib/api/analysis", async (importOriginal) => { error: null, }; if (options?.enabled === false) return idle; + // The pinned query is the one asking for a single row in date order. + // Checked BEFORE `contract`, because in a contract-scoped view the pin + // inherits the scope and both queries then carry it. + if (params.pageSize === 1 && params.sort === "date") { + const pinned = state.critical ? [state.critical] : []; + return { + data: { rows: pinned, total: pinned.length }, + isPending: false, + isError: false, + error: null, + }; + } + // An expanded group, or the whole table scoped to one contract. if (params.contract !== undefined) { return { data: { rows: state.groupAlerts, total: state.groupAlerts.length }, @@ -84,14 +97,7 @@ vi.mock("@/lib/api/analysis", async (importOriginal) => { error: null, }; } - // The pinned latest-critical row. - const rows = state.critical ? [state.critical] : []; - return { - data: { rows, total: rows.length }, - isPending: false, - isError: false, - error: null, - }; + return idle; }, }; }); @@ -247,14 +253,116 @@ describe("contract grouping", () => { expect(screen.queryByText("BBBBBBBB")).not.toBeInTheDocument(); }); + it("indents a child's CONTENT cell, not the empty gutter beside it", async () => { + // The original bug: the indent was applied to the gutter cell, which is + // empty on a child row, so it moved nothing and the transactions read as + // siblings of the contract holding them. Padding needs content to push. + state.groups = [group()]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage(); + fireEvent.click(screen.getByText("Djed StableCoin")); + const cell = screen.getByText("BBBBBBBB").closest("td"); + expect(cell?.className).toContain("pl-8"); + // And the gutter is not where the indent lives. + const gutter = cell?.previousElementSibling; + expect(gutter?.className).not.toContain("pl-8"); + }); + + it("runs a rule down the gutter, from the parent's chevron through its children", async () => { + // The device that says "these belong to the row above": a continuous line + // descending from the control that opened the group. Asserted on classes + // because jsdom computes no geometry for a pseudo-element. + state.groups = [group()]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage(); + fireEvent.click(screen.getByText("Djed StableCoin")); + const rows = screen.getAllByRole("row"); + const parent = rows.find((r) => r.textContent?.includes("Djed StableCoin")); + const child = screen.getByText("BBBBBBBB").closest("tr"); + // The parent draws it from its chevron down; the child spans its full height. + expect(parent?.children[0].className).toContain("before:top-1/2"); + // And it starts BELOW the glyph. The chevron is 1rem tall and vertically + // centred, so `top-1/2` alone begins the line at the glyph's middle and + // draws through its lower half, which is visible as the line crossing the + // arrow. The 0.5rem offset is that half. Asserted on the class because + // jsdom computes no geometry for a pseudo-element. + expect(parent?.children[0].className).toContain("before:mt-2"); + expect(child?.children[0].className).toContain("before:inset-y-0"); + for (const el of [parent?.children[0], child?.children[0]]) { + expect(el?.className).toContain("before:left-6"); + expect(el?.className).toContain("before:bg-border"); + } + }); + + it("insets a child's divider so it stops reading as a top-level row", async () => { + // A divider running the table's whole width is what says "top-level row". + // The child keeps a divider, but the gutter segment stays undrawn, which is + // exactly where the rule runs. + state.groups = [group()]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage(); + fireEvent.click(screen.getByText("Djed StableCoin")); + const child = screen.getByText("BBBBBBBB").closest("tr"); + expect(child?.className).toContain("border-b-0"); + expect(child?.className).toContain("[&>td:not(:first-child)]:border-b"); + }); + + it("tints the open contract, so the region has a top and the top is the control", async () => { + // While open, the contract row is both the heading of the rows beneath it + // and the row you click to close them. Neutral, not a severity colour, or it + // would compete with the badge on the same line. + state.groups = [group()]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage(); + const label = screen.getByText("Djed StableCoin"); + // The class LIST, not a substring: TableRow ships `hover:bg-muted/40`, so a + // substring check for the tint matches the hover state and always passes. + const classes = (el: Element | null | undefined) => + (el?.className ?? "").split(/\s+/); + expect(classes(label.closest("tr"))).not.toContain("bg-muted/40"); + fireEvent.click(label); + const open = screen.getByText("Djed StableCoin").closest("tr"); + expect(classes(open)).toContain("bg-muted/40"); + expect(classes(open)).toContain("hover:bg-muted/60"); + }); + + it("keeps the beyond-the-limit note inside the group it describes", async () => { + // Outside the rule it read as a note from the table itself, which is how + // "399 older alerts are not listed here" ends up looking as though it were + // about the whole list. + state.groups = [group({ alertCount: 400 })]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage(); + fireEvent.click(screen.getByText("Djed StableCoin")); + const note = screen + .getByText(/Showing the 1 most recent of 400/i) + .closest("td"); + expect(note?.className).toContain("before:left-6"); + // And it closes the region, since it is the last row in it. + expect(note?.className).toContain("after:h-px"); + }); + it("reports alerts beyond the expansion limit instead of hiding them", async () => { - // A truncated list must never read as the whole group. + // A truncated list must never read as the whole group. "The 1 most recent of + // 400" says both how many are shown and how many exist. state.groups = [group({ alertCount: 400 })]; state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; await renderPage(); fireEvent.click(screen.getByText("Djed StableCoin")); expect( - screen.getByText(/399 older alerts are not listed/i), + screen.getByText(/Showing the 1 most recent of 400/i), + ).toBeInTheDocument(); + }); + + it("offers a way to the surplus instead of only naming it", async () => { + // One expansion fetches one page and has no pager of its own, so the alerts + // beyond it used to be stated and then unreachable. + state.groups = [group({ alertCount: 400 })]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage(); + fireEvent.click(screen.getByText("Djed StableCoin")); + expect( + screen.getByRole("button", { name: /See all 400 for this contract/i }), ).toBeInTheDocument(); }); @@ -325,6 +433,100 @@ describe("contract grouping", () => { }); }); +describe("the contract-scoped view", () => { + it("clicking through to the surplus scopes the table and drops the page", async () => { + // The page number belonged to the grouped list. Carrying it over lands the + // operator on page 4 of a list they have just started reading. + state.groups = [group({ alertCount: 400 })]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage("/dashboard?page=4"); + fireEvent.click(screen.getByText("Djed StableCoin")); + fireEvent.click( + screen.getByRole("button", { name: /See all 400 for this contract/i }), + ); + expect(screen.getByTestId("location").textContent).toBe( + `/dashboard?contract=${DJED}`, + ); + }); + + it("lists that contract's alerts flat, with no grouping left to do", async () => { + // Grouping by contract is pointless once one contract is chosen. + state.groups = [group()]; + state.groupAlerts = [ + alert({ fullHash: `${"b".repeat(63)}2` }), + alert({ fullHash: `${"c".repeat(63)}3` }), + ]; + await renderPage(`/dashboard?contract=${DJED}`); + expect(screen.getByText("BBBBBBBB")).toBeInTheDocument(); + expect(screen.getByText("CCCCCCCC")).toBeInTheDocument(); + // The group summary row is gone: no chevron, no alert count. + expect(screen.queryByText("12 alerts")).not.toBeInTheDocument(); + }); + + it("names the scope and offers the way out of it", async () => { + // Without the chip the table looks like a short, unexplained list. The URL + // says why, and nobody reads the URL. + state.groups = [group()]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage(`/dashboard?contract=${DJED}`); + expect(screen.getByText("Djed StableCoin")).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: /Show all contracts again/i }), + ); + expect(screen.getByTestId("location").textContent).toBe("/dashboard"); + }); + + it("falls back to a truncated address when the registry has no label", async () => { + state.groups = [group()]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage(`/dashboard?contract=${STRIKE}`); + // Scoped to the chip: the test harness prints the URL, which contains the + // full address, so a document-wide text query matches that instead. + const chip = screen + .getByRole("button", { name: /Show all contracts again/i }) + .closest("span"); + expect(chip?.textContent).toContain(STRIKE.slice(0, 14)); + expect(chip?.textContent).not.toContain("Djed StableCoin"); + }); + + it("does not repeat the contract on every row", async () => { + // The chip states it once. Restating one fact ten times is the noise the + // grouping was built to remove. + state.groups = [group()]; + state.groupAlerts = [ + alert({ fullHash: `${"b".repeat(63)}2`, contractAddress: DJED }), + alert({ fullHash: `${"c".repeat(63)}3`, contractAddress: DJED }), + ]; + await renderPage(`/dashboard?contract=${DJED}`); + // Asserted on the cell, not on the label: a scoped row is given no registry + // label, so dropping the suppression would repeat the truncated ADDRESS and + // an assertion about "Djed StableCoin" would pass while the bug was present. + for (const hash of ["BBBBBBBB", "CCCCCCCC"]) { + const cell = screen.getByText(hash).closest("td"); + expect(cell?.textContent?.trim()).toBe(hash); + } + // And the chip still names it, once. + expect(screen.getAllByText("Djed StableCoin")).toHaveLength(1); + }); + + it("holds the grouped query off while scoped, and the scoped one while grouped", async () => { + // Both hooks honour `enabled`, so the view that is not shown is not fetched. + // Asserted through the mock: an un-held query would return rows here. + state.groups = [group()]; + state.groupAlerts = [alert({ fullHash: `${"b".repeat(63)}2` })]; + await renderPage(`/dashboard?contract=${DJED}`); + // Scoped: the group row's label is absent from the body, so the grouped + // query produced nothing. + expect(screen.queryByText("12 alerts")).not.toBeInTheDocument(); + cleanup(); + await renderPage("/dashboard"); + // Grouped: the child rows are absent until a group is expanded, so the + // scoped query produced nothing. + expect(screen.queryByText("BBBBBBBB")).not.toBeInTheDocument(); + expect(screen.getByText("12 alerts")).toBeInTheDocument(); + }); +}); + describe("alerts with no contract", () => { it("renders as an ordinary row, not behind a chevron", async () => { state.groups = [ diff --git a/frontend/src/pages/AttacksPage.tsx b/frontend/src/pages/AttacksPage.tsx index 3718e4e..c606eed 100644 --- a/frontend/src/pages/AttacksPage.tsx +++ b/frontend/src/pages/AttacksPage.tsx @@ -24,6 +24,8 @@ import { import { ALERT_COLUMN_COUNT, AlertRow, + CONTRACT_HEAD, + CONTRACT_TAIL, ContractGroupRow, PinnedCriticalSpacerRow, } from "@/components/alerts/alert-rows"; @@ -54,7 +56,7 @@ import { type Severity, } from "@/lib/attacks"; import { AttackDetailPage } from "@/pages/AttackDetailPage"; -import { ArrowUp, Info } from "lucide-react"; +import { ArrowUp, Info, X } from "lucide-react"; import { Fragment, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import Sparkline from "@/components/sparkline"; @@ -145,6 +147,16 @@ export function AttacksPage() { const pageSize = parsePageSize(searchParams.get("size")); // 1-based in the URL, 0-based in state. const page = Math.max(0, (Number(searchParams.get("page")) || 1) - 1); + // Scoping the whole table to one contract. Set from the note that reports the + // alerts an expansion could not list: a group holding more than + // GROUP_EXPANSION_LIMIT alerts had no way to show the rest, so the note told + // the truth and led nowhere. + // + // An empty value is treated as absent. `contract=''` is meaningful to the API + // (it selects exactly the alerts that name NO contract) but that is the + // grouped view's own business, and reaching it from the URL would produce a + // view that cannot be described or cleared. + const contractScope = searchParams.get("contract") || undefined; // Which contract group is expanded. Single-open, matching ClusterSummaryTable. const [expandedContract, setExpandedContract] = useState(null); @@ -156,11 +168,18 @@ export function AttacksPage() { severities: severities.length > 0 ? severities : undefined, }; - const { data, isPending, isError, error } = useGroupedAlerts({ - ...filters, - page, - pageSize, - }); + // Grouping by contract is pointless once a contract is chosen, so the two + // views are exclusive and the unused query is held off rather than fetched + // and discarded. + const grouped = useGroupedAlerts( + { ...filters, page, pageSize }, + { enabled: !contractScope }, + ); + const scoped = useRiskAlerts( + { ...filters, contract: contractScope, page, pageSize }, + { enabled: Boolean(contractScope) }, + ); + const { isPending, isError, error } = contractScope ? scoped : grouped; // The pinned latest-critical row. // @@ -181,6 +200,9 @@ export function AttacksPage() { const { data: criticalData } = useRiskAlerts( { ...filters, + // Inherits the contract scope for the same reason it inherits the attack + // type: a pin from outside the view contradicts the view. + contract: contractScope, severities: ["CRITICAL"], page: 0, pageSize: 1, @@ -213,20 +235,28 @@ export function AttacksPage() { // would be neither free (a 1-row page still runs the contract_anomaly recall // rescue) nor right (that rescue's date floor collapses to the single row it // returned, so the count would omit the anomaly alerts this table shows). - const total = data?.total ?? 0; - const totalAlerts = data?.alertTotal ?? 0; + // Scoped to one contract, rows ARE alerts, so the pager's unit and the alert + // count are the same number. Grouped, they differ: one group row can stand for + // dozens of alerts, which is why the endpoint reports both. + const total = contractScope + ? (scoped.data?.total ?? 0) + : (grouped.data?.total ?? 0); + const totalAlerts = contractScope ? total : (grouped.data?.alertTotal ?? 0); // Backend already anti-joins `archived_alerts` from `/api/v1/analysis/results`, // so the rows we get are guaranteed not archived. No client filter needed. - const visibleRows = data?.rows ?? []; + const visibleRows = grouped.data?.rows ?? []; + const scopedRows = scoped.data?.rows ?? []; const pageCount = Math.max(1, Math.ceil(total / pageSize)); const currentPage = Math.min(page, pageCount - 1); // Rows that will actually render. An un-attributed row missing its tx identity // is skipped, so keying the empty state off visibleRows.length could leave the // table with no rows AND no message. - const renderableRows = visibleRows.filter( - (r) => r.kind === "group" || groupedRowToAlert(r) !== null, - ); + const renderableRows = contractScope + ? scopedRows + : visibleRows.filter( + (r) => r.kind === "group" || groupedRowToAlert(r) !== null, + ); // A filter change invalidates the page number and any open group. const setFilterParam = (key: string, value: string | null) => { @@ -243,6 +273,16 @@ export function AttacksPage() { setExpandedContract(null); setParam("page", next === 0 ? null : String(next + 1)); }; + // Entering the scope drops the page, or the operator lands on page 4 of a list + // they have just started reading, and closes the expansion that led here, + // which has no meaning once the whole table is that contract. + const scopeToContract = (address: string) => { + setExpandedContract(null); + setParam("contract", address, { alsoDelete: ["page"] }); + }; + const clearContractScope = () => { + setParam("contract", null, { alsoDelete: ["page"] }); + }; // Carry the query string across the detail route. The filters live in the URL // now, and `/attacks/:id` renders this same page under the dialog, so // navigating without them would reset the table behind the popup and leave the @@ -310,9 +350,31 @@ export function AttacksPage() { {/* Risk Alerts */}
-

- Risk Alerts -

+
+

+ Risk Alerts +

+ {/* Names the scope and is the way out of it. Without it the table + looks like a short, unexplained list: the URL says why, and + nobody reads the URL. */} + {contractScope && ( + + + {contractLabels.get(contractScope) ?? + shortHash(contractScope, CONTRACT_HEAD, CONTRACT_TAIL)} + + + + )} +