milestone 29: activity — history, live and policy simulation on one surface
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s

query log, live and lookup merge into /activity. history filters live
in the url, so a pasted link or back/forward reproduces the exact
view; the result column separates servfail and nxdomain from success
in the list. live is follow-by-default with freeze, and a streamed
row opens its in-memory provenance detail — no correlation invented
for rows sqlite has not written. lookup survives as the current
policy simulation under /activity/test. investigation links carry
absolute bounds, and the diagnostics page now honors since/until
instead of ignoring them. the old routes are gone without aliases.
This commit is contained in:
2026-08-22 10:52:56 +02:00
parent 0fd6bbd312
commit fa323c7ed4
42 changed files with 3225 additions and 1426 deletions
+114
View File
@@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import type { QueryRow } from "@/lib/types";
import type { ClientNames } from "@/features/clients/clientNames";
import { queryRow } from "@/features/queries/provenanceFixture";
import { summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
const noNames: ClientNames = new Map();
function renderRow(overrides: Partial<QueryRow> = {}): HTMLTableRowElement {
const row: QuerySummary = summarizeRow(queryRow(1, overrides));
render(
<table>
<ActivityTableHead />
<tbody>
<tr data-testid="row">
<ActivityCells
row={row}
clientNames={noNames}
renderDomain={(id, children) => <a href={`/activity/queries/${id}`}>{children}</a>}
/>
</tr>
</tbody>
</table>,
);
return screen.getByTestId("row") as HTMLTableRowElement;
}
/** The cell under a header, read by its column name rather than its index. */
function cell(row: HTMLTableRowElement, column: (typeof ACTIVITY_COLUMNS)[number]): string {
const index = ACTIVITY_COLUMNS.indexOf(column);
return row.cells[index]?.textContent ?? "";
}
test("the head names the seven columns in order", () => {
render(
<table>
<ActivityTableHead />
</table>,
);
const headers = screen.getAllByRole("columnheader").map((header) => header.textContent);
expect(headers).toEqual(["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"]);
});
test("an allowed NOERROR row reads as the answer it got, with no badge", () => {
const row = renderRow({ blocked: false, rcode: 0, route_kind: "upstream", response_time_us: 1234 });
expect(cell(row, "Result")).toBe("NOERROR");
expect(cell(row, "Route")).toBe("Upstream");
expect(cell(row, "Duration")).toBe("1.2 ms");
expect(cell(row, "Type")).toBe("A");
});
test("a blocked row reads Blocked even though the client got NOERROR", () => {
const row = renderRow({ blocked: true, rcode: 0, route_kind: "blocked", policy_reason: "blocklist_domain" });
expect(cell(row, "Result")).toBe("Blocked");
expect(cell(row, "Route")).toBe("Blocked");
});
test("a SERVFAIL row names the code", () => {
const row = renderRow({ blocked: false, rcode: 2, route_kind: "upstream", response_time_us: null });
expect(cell(row, "Result")).toBe("SERVFAIL");
expect(cell(row, "Duration")).toBe("—");
});
test("a cache hit names the cache as the route", () => {
const row = renderRow({ blocked: false, rcode: 0, route_kind: "cache", cache_hit: true, upstream: "" });
expect(cell(row, "Route")).toBe("Cache");
expect(cell(row, "Result")).toBe("NOERROR");
});
test("an unassigned extended rcode keeps the numeric fallback, without the long form's parentheses", () => {
const row = renderRow({ blocked: false, rcode: 3841 });
expect(cell(row, "Result")).toBe("RCODE 3841");
});
test("a persisted row links its domain to the detail the caller chose", () => {
renderRow({ domain: "ads.example" });
expect(screen.getByRole("link", { name: "ads.example" }).getAttribute("href")).toBe("/activity/queries/1");
});
test("a streamed row reaches the renderer with a null id, and can render as plain text", () => {
render(
<table>
<tbody>
<tr data-testid="row">
<ActivityCells
row={{ ...summarizeRow(queryRow(1)), id: null }}
clientNames={noNames}
renderDomain={(id, children) => {
expect(id).toBeNull();
return children;
}}
/>
</tr>
</tbody>
</table>,
);
expect(screen.queryByRole("link")).toBeNull();
expect(screen.getByTestId("row").textContent).toContain("example.com");
});
test("every route kind has a compact label", () => {
expect(routeLabel("blocked")).toBe("Blocked");
expect(routeLabel("local")).toBe("Local");
expect(routeLabel("forward_zone")).toBe("Forward zone");
expect(routeLabel("upstream")).toBe("Upstream");
expect(routeLabel("cache")).toBe("Cache");
expect(routeLabel("rejected")).toBe("Rejected");
});
test("resultLabel is the pure form of the Result cell", () => {
expect(resultLabel({ blocked: true, rcode: 2 })).toBe("Blocked");
expect(resultLabel({ blocked: false, rcode: 5 })).toBe("REFUSED");
});