admin: overview redesign, device scope, one formatting contract (milestone 39)
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s

The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only.

GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry.

All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else.

Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash.

Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round.

Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
This commit is contained in:
2026-09-07 23:11:50 +02:00
parent e656670dd4
commit 85b8be50a0
77 changed files with 2869 additions and 1163 deletions
+142 -47
View File
@@ -14,7 +14,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { clientKey, qtypeKey, seriesColor } from "./seriesColors";
import { clientSeriesColor, typeRampColor } from "./seriesColors";
import { health } from "@/lib/healthFixture";
import type { Health, Overview } from "@/lib/types";
@@ -144,6 +144,26 @@ function panel(name: string): HTMLElement {
return section;
}
/** The toolbar's two selectors, named by their aria-label (RAC folds the value into the name too). */
function periodTrigger(): HTMLElement {
return screen.getByRole("button", { name: /Period/ });
}
function deviceTrigger(): HTMLElement {
return screen.getByRole("button", { name: /Device/ });
}
/** RAC opens a Select from the keyboard as readily as from a pointer. */
function open(trigger: HTMLElement) {
fireEvent.keyDown(trigger, { key: "Enter" });
fireEvent.keyUp(trigger, { key: "Enter" });
}
/** Whether any request so far carried this query string fragment. */
function requested(fragment: string): boolean {
return vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes(fragment));
}
test("the root path lands on Overview rather than aliasing it", async () => {
const router = renderApp("/");
await screen.findByRole("heading", { name: "Overview", level: 1 });
@@ -155,7 +175,7 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
// one panel sharing a hue. The stroke is what stops neighbours from merging
// into one shape, which makes it part of the contract rather than decoration.
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
const arcs = Array.from(panel("Query types").querySelectorAll("svg path"));
@@ -167,16 +187,20 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
}
});
test("the page builds a donut slice's colour from the entry's identity", async () => {
test("the types ring steps the accent's ramp from the busiest type outward", async () => {
// `Donut` renders the colour it is handed and never recomputes one, so the
// mapping from identity to hue is the page's job and is pinned here.
// mapping from rank to ramp step is the page's job and is pinned here.
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
const item = within(panel("Query types")).getAllByText("A")[0].closest("li") as HTMLElement;
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
expect(swatch.getAttribute("style")).toContain(seriesColor(qtypeKey(1)));
const swatchOf = (label: string) => {
const item = within(panel("Query types")).getAllByText(label)[0].closest("li") as HTMLElement;
return (item.querySelector("span[aria-hidden]") as HTMLElement).getAttribute("style");
};
expect(swatchOf("A")).toContain(typeRampColor(0));
expect(swatchOf("AAAA")).toContain(typeRampColor(1));
expect(swatchOf("Unknown")).toContain(typeRampColor(2));
});
test("a request in flight leaves the heading and the picker usable behind one loading surface", async () => {
@@ -189,7 +213,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
expect(periodTrigger().textContent).toContain("Last 24 hours");
// One loading state for the whole page, not one per panel.
const loading = await screen.findByText("Loading…");
expect(loading.getAttribute("role")).toBe("status");
@@ -198,7 +222,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
release();
delayed = null;
await screen.findByText("1,000");
await screen.findAllByText("1,000");
expect(screen.queryByText("Loading…")).toBeNull();
});
@@ -234,9 +258,9 @@ test("a client named only by reverse DNS is named by it too", async () => {
await waitFor(() => expect(within(chart).getAllByText("laptop.lan")).toHaveLength(2));
});
test("naming a client does not recolour its series", async () => {
test("naming a client does not recolour its series: colour goes by rank", async () => {
// The rename the palette must not notice: the swatch beside "kitchen-pi" is
// the colour of the address it was drawn under, not of the label on screen.
// the busiest client's hue, whatever the label on screen says.
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
renderApp();
const chart = await waitFor(() => panel("Client activity over time"));
@@ -244,13 +268,13 @@ test("naming a client does not recolour its series", async () => {
const item = within(chart).getAllByText("kitchen-pi")[0].closest("li") as HTMLElement;
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
expect(swatch.getAttribute("style")).toContain(seriesColor(clientKey("192.0.2.30")));
expect(swatch.getAttribute("style")).toContain(clientSeriesColor(0));
});
test("the client chart drops Other in a period where it counted nothing", async () => {
// The fixture's other series is all zeroes. An aggregation bucket that
// aggregated nothing is a legend entry and a table column that say only that
// they are empty; the named clients stay, because a quiet client is a fact.
test("the client chart keeps Other in a period where it counted nothing", async () => {
// The fixture's other series is all zeroes. Other is still a series, so the
// legend reads the same in every scope and its zero says the named clients
// were the whole story.
renderApp();
await screen.findByRole("heading", { name: "Client activity over time" });
@@ -259,17 +283,27 @@ test("the client chart drops Other in a period where it counted nothing", async
// Twice each: the legend swatch and the column header of the table a screen
// reader gets instead of the graphic.
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2));
expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0);
expect(within(chart as HTMLElement).getAllByText("Other")).toHaveLength(2);
});
test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => {
test("the page is four tiles, two charts, the cache card and two donuts — no status or issues sections", async () => {
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
await screen.findByText("1,000");
await screen.findAllByText("1,000");
for (const name of ["Queries over time", "Client activity over time", "Query types", "Upstream servers"]) {
for (const name of [
"Queries over time",
"Client activity over time",
"Cache hit rate",
"Query types",
"Upstream servers",
]) {
expect(screen.getByRole("heading", { name })).toBeTruthy();
}
// Every card leads with its title and a one-line description under it.
for (const section of screen.getAllByRole("region")) {
expect(section.querySelector("h2 + p")?.textContent).toBeTruthy();
}
// The sections the layout ruling removed, and the widgets the Dashboard lost.
expect(screen.queryByRole("heading", { name: "Current status" })).toBeNull();
expect(screen.queryByRole("heading", { name: "Active issues" })).toBeNull();
@@ -280,11 +314,18 @@ test("the page is four tiles, two charts and two donuts — no status or issues
test("the four tiles report the window, and each links where its number leads", async () => {
renderApp();
const tiles = within((await screen.findByText("1,000")).closest("dl") as HTMLElement);
await screen.findAllByText("1,000");
const tiles = within(screen.getByRole("list", { name: "Totals" }));
expect(tiles.getByText("1,000")).toBeTruthy();
expect(tiles.getByText("250")).toBeTruthy();
expect(tiles.getByText("25.0%")).toBeTruthy();
expect(tiles.getByText("25.00%")).toBeTruthy();
expect(tiles.getByText("7")).toBeTruthy();
expect(tiles.getByText("2.3 ms")).toBeTruthy();
expect(tiles.getAllByRole("listitem").map((item) => item.querySelector("p + p")?.textContent)).toEqual([
"queries",
"blocked queries",
"active clients",
"of queries blocked",
]);
// The bounds are the ones the stats response returned, not ones computed here.
const queries = new URLSearchParams(
@@ -306,9 +347,23 @@ test("the four tiles report the window, and each links where its number leads",
expect(screen.queryByRole("link", { name: /average/i })).toBeNull();
});
test("the cache card reads the hit share off the buckets and the forwarded count off the routes", async () => {
renderApp();
await screen.findAllByText("1,000");
const cache = within(panel("Cache hit rate"));
// 10 cached answers of 1,000 queries; 500 + 100 went to an upstream.
expect(cache.getByText("1.00%")).toBeTruthy();
expect(cache.getByRole("img", { name: "1.00% of queries served from cache" })).toBeTruthy();
expect(cache.getByText("10")).toBeTruthy();
expect(cache.getByText("600")).toBeTruthy();
expect(cache.getByText("2.3 ms")).toBeTruthy();
expect(cache.getByText("of queries answered from cache")).toBeTruthy();
});
test("both donuts name every entry, nulls included, and disambiguate a nameless source", async () => {
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
const types = within(panel("Query types"));
expect(types.getByRole("rowheader", { name: "A" })).toBeTruthy();
@@ -326,7 +381,7 @@ test("both donuts name every entry, nulls included, and disambiguate a nameless
test("the donut ring is decoration; the legend and the hidden table are the accessible surface", async () => {
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
const svg = panel("Query types").querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
@@ -336,47 +391,87 @@ test("the donut ring is decoration; the legend and the hidden table are the acce
test("an empty window says so in every panel instead of drawing nothing", async () => {
renderApp("/overview?period=1h");
await screen.findByText("12");
await screen.findAllByText("12");
// The two donuts and the client chart; the query-volume chart says it too.
expect(screen.getAllByText("No queries in this period.").length).toBe(4);
});
test("a deep link opens on the period it names", async () => {
test("a deep link opens on the period it names, and the picker offers the four in words", async () => {
renderApp("/overview?period=1h");
await screen.findByText("12");
// One radio group named Period, holding the four periods and exactly one
// selection: the segmented picker is a single choice, not four toggles.
const picker = within(screen.getByRole("radiogroup", { name: "Period" }));
expect(picker.getAllByRole("radio").map((radio) => radio.getAttribute("value"))).toEqual([
"1h",
"24h",
"7d",
"30d",
await screen.findAllByText("12");
expect(periodTrigger().textContent).toContain("Last hour");
open(periodTrigger());
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
"Last hour",
"Last 24 hours",
"Last 7 days",
"Last 30 days",
]);
expect(picker.getByRole("radio", { name: "1h", checked: true })).toBeTruthy();
expect(picker.getByRole("radio", { name: "24h", checked: false })).toBeTruthy();
});
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
const router = renderApp("/overview?period=90d");
await screen.findByText("1,000");
expect(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
await screen.findAllByText("1,000");
expect(periodTrigger().textContent).toContain("Last 24 hours");
expect(router.state.location.search).toEqual({});
});
test("the picker rescopes every panel and writes the period into the url", async () => {
const router = renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
fireEvent.click(screen.getByRole("radio", { name: "1h" }));
open(periodTrigger());
fireEvent.click(screen.getByRole("option", { name: "Last hour" }));
await screen.findByText("12");
await screen.findAllByText("12");
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
// No panel is left describing the period the reader left.
expect(screen.queryByText("1,000")).toBeNull();
});
test("a deep link to one device scopes the request, the tiles' links and the picker", async () => {
renderApp("/overview?client=192.0.2.31");
await screen.findAllByText("1,000");
expect(requested("client=192.0.2.31")).toBe(true);
// Not registered, so the picker shows the address rather than claiming the household.
expect(deviceTrigger().textContent).toContain("192.0.2.31");
const queries = new URLSearchParams(
screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "",
);
expect(queries.get("client")).toBe("192.0.2.31");
});
test("the device picker names the registered clients and writes the choice into the url", async () => {
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
const router = renderApp();
await screen.findAllByText("1,000");
expect(deviceTrigger().textContent).toContain("All devices");
// The clients list has landed once the chart names the client by it.
await waitFor(() => expect(within(panel("Client activity over time")).getAllByText("kitchen-pi")).toHaveLength(2));
open(deviceTrigger());
fireEvent.click(screen.getByRole("option", { name: "kitchen-pi" }));
await waitFor(() => expect(router.state.location.search).toEqual({ client: "192.0.2.30" }));
await waitFor(() => expect(requested("client=192.0.2.30")).toBe(true));
expect(deviceTrigger().textContent).toContain("kitchen-pi");
// Back to the household drops the parameter rather than writing an empty one.
open(deviceTrigger());
fireEvent.click(screen.getByRole("option", { name: "All devices" }));
await waitFor(() => expect(router.state.location.search).toEqual({}));
});
test("a client list in the url is not this page's grammar and is dropped", async () => {
const router = renderApp("/overview?client=192.0.2.30,192.0.2.31");
await screen.findAllByText("1,000");
expect(router.state.location.search).toEqual({});
expect(requested("client=")).toBe(false);
});
test("a failed request is one error for the whole page, stated once and retryable", async () => {
failing = true;
renderApp();
@@ -388,18 +483,18 @@ test("a failed request is one error for the whole page, stated once and retryabl
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
// The heading and the picker survive it, so the reader can rescope or retry.
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
expect(periodTrigger()).toBeTruthy();
expect(screen.queryByText("Something went wrong")).toBeNull();
failing = false;
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await screen.findByText("1,000");
await screen.findAllByText("1,000");
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
});
test("an incomplete window states its watermark once for the whole page", async () => {
coverageComplete = false;
renderApp();
await screen.findByText("1,000");
await screen.findAllByText("1,000");
expect(screen.getAllByText(/Query history is available from/)).toHaveLength(1);
});