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
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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user