Files
nxdns/admin/src/features/overview/OverviewPage.test.tsx
T
mokhtar c65d92d8f8 admin: title-only information becomes visible text
the client address follows its name as visible muted text in the query tables, the config lock indicator prints its reason beside the tag except in table rows where a page-level note explains the lock instead, and the locked delete buttons describe themselves through that one visible note. the chart legend tooltip is deleted because a named client is deliberately not addressed in the chart, and the dead series address field went with it. titles that merely repeat visible copyable text stay.
2026-08-29 13:03:30 +02:00

406 lines
17 KiB
TypeScript

/**
* Overview through the real router: Pi-hole's layout over our data.
*
* The behaviours of the superseded three-section build are accounted for here or
* declared dead. The status rows and the issues list moved to the Diagnostics
* page's health strip and its Active section; the Pause control moved to the
* sidebar; the protection indicator is gone. What stays here is the period, the
* window and the panels.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
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 { health } from "@/lib/healthFixture";
import type { Health, Overview } from "@/lib/types";
const SINCE = Date.UTC(2026, 0, 1, 0, 0) / 1000;
const UNTIL = Date.UTC(2026, 0, 2, 0, 0) / 1000;
const COVERAGE = { complete: true, available_since: SINCE };
const OVERVIEW: Overview = {
period: "24h",
since: SINCE,
until: UNTIL,
bucket_seconds: 1800,
totals: { queries: 1000, blocked: 250, clients: 7, avg_response_time_us: 2345 },
buckets: [
{ ts: SINCE, queries: 60, blocked: 20, cached: 10 },
{ ts: SINCE + 1800, queries: 40, blocked: 0, cached: 0 },
],
clients: [
{ client: "192.0.2.30", buckets: [40, 20] },
{ client: "192.0.2.31", buckets: [20, 20] },
],
other: [0, 0],
types: [
{ qtype: 1, count: 600 },
{ qtype: 28, count: 300 },
{ qtype: null, count: 100 },
],
routes: [
{ route: "upstream", source: "https://dns.example/dns-query", count: 500 },
{ route: "blocked", source: null, count: 250 },
{ route: "cache", source: null, count: 150 },
{ route: "upstream", source: null, count: 100 },
],
coverage: COVERAGE,
};
/** The same shape an hour wide and empty, so a period change is observable. */
const HOUR: Overview = {
...OVERVIEW,
period: "1h",
since: UNTIL - 3600,
bucket_seconds: 60,
totals: { queries: 12, blocked: 3, clients: 2, avg_response_time_us: 2345 },
buckets: [],
clients: [],
other: [],
types: [],
routes: [],
};
let healthBody: Health;
let failing: boolean;
/** The registered clients, as `/api/clients` answers them. */
let registered: { ip: string; name: string; learned_name: string }[];
let coverageComplete: boolean;
/** Held in flight, so a test can look at the page while the request is pending. */
let delayed: Promise<void> | null;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
function withCoverage<T extends { coverage: typeof COVERAGE }>(body: T): T {
return { ...body, coverage: { ...body.coverage, complete: coverageComplete } };
}
beforeEach(() => {
healthBody = health();
failing = false;
registered = [];
coverageComplete = true;
delayed = null;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith("/api/overview")) {
if (failing) return json({ error: "endpoint unavailable" }, 400);
if (delayed !== null) await delayed;
return json(withCoverage(url.includes("period=1h") ? HOUR : OVERVIEW));
}
if (url === "/api/clients") {
return json({
clients: registered.map((client, index) => ({
id: index + 1,
ip: client.ip,
name: client.name,
learned_name: client.learned_name,
group_id: 1,
group: "default",
hand_edited: client.name !== "",
first_seen: SINCE,
last_seen: UNTIL,
})),
});
}
if (url === "/api/health") return json(healthBody);
if (url === "/api/version")
return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 });
if (url.startsWith("/api/diagnostics")) {
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
}
return json({ error: "not stubbed" }, 404);
}),
);
});
afterEach(() => vi.unstubAllGlobals());
function renderApp(path = "/overview") {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
function panel(name: string): HTMLElement {
const heading = screen.getByRole("heading", { name });
const section = heading.closest("section");
if (section === null) throw new Error(`no panel for ${name}`);
return section;
}
test("the root path lands on Overview rather than aliasing it", async () => {
const router = renderApp("/");
await screen.findByRole("heading", { name: "Overview", level: 1 });
expect(router.state.location.pathname).toBe("/overview");
});
test("every donut arc is outlined, so two slices of one hue still read as two", async () => {
// Colour is a pure function of identity and so cannot rule out two slices of
// 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 waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
const arcs = Array.from(panel("Query types").querySelectorAll("svg path"));
expect(arcs).toHaveLength(3);
for (const arc of arcs) {
expect(arc.getAttribute("stroke-width")).toBe("1");
// The panel's own surface colour, as a token reference.
expect(arc.getAttribute("stroke")).toMatch(/^var\(--/);
}
});
test("the page builds a donut slice's colour from the entry's identity", 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.
renderApp();
await screen.findByText("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)));
});
test("a request in flight leaves the heading and the picker usable behind one loading surface", async () => {
// Through the real route, which is the point: the loader starts the request
// and awaits it nowhere. If it awaited, the router would hold the whole page —
// heading and period picker included — until the response landed.
let release = () => {};
delayed = new Promise<void>((resolve) => (release = resolve));
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
// One loading state for the whole page, not one per panel.
const loading = await screen.findByText("Loading…");
expect(loading.getAttribute("role")).toBe("status");
expect(screen.getAllByText("Loading…")).toHaveLength(1);
expect(screen.queryByRole("heading", { name: "Query types" })).toBeNull();
release();
delayed = null;
await screen.findByText("1,000");
expect(screen.queryByText("Loading…")).toBeNull();
});
test("a registered client is named in the chart, an unregistered one keeps its address", async () => {
// The fixture's two clients: one registered with a typed name, one the clients
// list has never seen.
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
renderApp();
const chart = await waitFor(() => panel("Client activity over time"));
// Legend and the hidden table both, since the table is what a screen reader
// gets instead of the graphic and the two must not name one client differently.
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
// Nor on hover: a pointer-only tooltip would say what the design just chose
// not to, and only to a reader holding a mouse.
const legendItem = within(chart)
.getAllByText("kitchen-pi")
.map((node) => node.closest("li"))
.find((node) => node !== null);
expect(legendItem).toBeTruthy();
expect(legendItem?.getAttribute("title")).toBeNull();
expect(within(chart).getByRole("list").querySelectorAll("[title]")).toHaveLength(0);
});
test("a client named only by reverse DNS is named by it too", async () => {
registered = [{ ip: "192.0.2.31", name: "", learned_name: "laptop.lan" }];
renderApp();
const chart = await waitFor(() => panel("Client activity over time"));
await waitFor(() => expect(within(chart).getAllByText("laptop.lan")).toHaveLength(2));
});
test("naming a client does not recolour its series", 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.
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
renderApp();
const chart = await waitFor(() => panel("Client activity over time"));
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
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")));
});
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.
renderApp();
await screen.findByRole("heading", { name: "Client activity over time" });
const chart = screen.getByRole("heading", { name: "Client activity over time" }).closest("section");
expect(chart).toBeTruthy();
// 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);
});
test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => {
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
await screen.findByText("1,000");
for (const name of ["Queries over time", "Client activity over time", "Query types", "Upstream servers"]) {
expect(screen.getByRole("heading", { name })).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();
expect(screen.queryByRole("heading", { name: "Activity over a period" })).toBeNull();
expect(screen.queryByText("Storage now")).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Upstream" })).toBeNull();
});
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);
expect(tiles.getByText("250")).toBeTruthy();
expect(tiles.getByText("25.0%")).toBeTruthy();
expect(tiles.getByText("7")).toBeTruthy();
expect(tiles.getByText("2.3 ms")).toBeTruthy();
// The bounds are the ones the stats response returned, not ones computed here.
const queries = new URLSearchParams(
screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "",
);
expect(queries.get("mode")).toBe("history");
expect(queries.get("since")).toBe(String(SINCE));
expect(queries.get("until")).toBe(String(UNTIL));
expect(queries.get("blocked")).toBeNull();
const blocked = new URLSearchParams(
screen.getByRole("link", { name: "Open blocked queries" }).getAttribute("href")?.split("?")[1] ?? "",
);
expect(blocked.get("blocked")).toBe("true");
expect(blocked.get("since")).toBe(String(SINCE));
expect(screen.getByRole("link", { name: "Manage clients" }).getAttribute("href")).toBe("/clients");
// Average response time has no rows behind it to open.
expect(screen.queryByRole("link", { name: /average/i })).toBeNull();
});
test("both donuts name every entry, nulls included, and disambiguate a nameless source", async () => {
renderApp();
await screen.findByText("1,000");
const types = within(panel("Query types"));
expect(types.getByRole("rowheader", { name: "A" })).toBeTruthy();
expect(types.getByRole("rowheader", { name: "AAAA" })).toBeTruthy();
// A query whose type was never recorded is its own entry, not a dropped row.
expect(types.getByRole("rowheader", { name: "Unknown" })).toBeTruthy();
const routes = within(panel("Upstream servers"));
expect(routes.getByRole("rowheader", { name: "https://dns.example/dns-query (Upstream)" })).toBeTruthy();
expect(routes.getByRole("rowheader", { name: "Blocked" })).toBeTruthy();
expect(routes.getByRole("rowheader", { name: "Cache" })).toBeTruthy();
// An upstream row with no recorded resolver reads as Unknown, qualified by its kind.
expect(routes.getByRole("rowheader", { name: "Unknown (Upstream)" })).toBeTruthy();
});
test("the donut ring is decoration; the legend and the hidden table are the accessible surface", async () => {
renderApp();
await screen.findByText("1,000");
const svg = panel("Query types").querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
expect(svg?.getAttribute("focusable")).toBe("false");
expect(within(panel("Query types")).getByRole("table")).toBeTruthy();
});
test("an empty window says so in every panel instead of drawing nothing", async () => {
renderApp("/overview?period=1h");
await screen.findByText("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 () => {
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",
]);
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();
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");
fireEvent.click(screen.getByRole("radio", { name: "1h" }));
await screen.findByText("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 failed request is one error for the whole page, stated once and retryable", async () => {
failing = true;
renderApp();
await screen.findByText("endpoint unavailable");
// One statement of the failure, not one per panel: there is a single request
// behind every panel, so a second copy would only repeat this sentence.
expect(screen.getAllByText("endpoint unavailable")).toHaveLength(1);
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(screen.queryByText("Something went wrong")).toBeNull();
failing = false;
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await screen.findByText("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");
expect(screen.getAllByText(/Query history is available from/)).toHaveLength(1);
});