the hand-written scale, tick, stacking and arc math is replaced by visx 4.0.0 primitives; rendering, colours and themes stay the app's own. all four charts share one hover treatment: the client chart gains the tooltip and dimming the query timeline had, the donuts gain both, an open tooltip follows a data refresh instead of going stale, and it retires when the window rolls. the timeline's third series is named allowed instead of other, and the client chart's other aggregate disappears from a window where it counted nothing. licenses gain the isc text for the bundled d3 modules.
414 lines
17 KiB
TypeScript
414 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, StatsClients, StatsRoutes, StatsTimeseries, StatsTotals, StatsTypes } 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 TOTALS: StatsTotals = {
|
|
period: "24h",
|
|
since: SINCE,
|
|
until: UNTIL,
|
|
queries: 1000,
|
|
blocked: 250,
|
|
clients: 7,
|
|
avg_response_time_us: 2345,
|
|
coverage: COVERAGE,
|
|
};
|
|
|
|
const SERIES: StatsTimeseries = {
|
|
period: "24h",
|
|
since: SINCE,
|
|
until: UNTIL,
|
|
bucket_seconds: 1800,
|
|
buckets: [
|
|
{ ts: SINCE, queries: 60, blocked: 20, cached: 10 },
|
|
{ ts: SINCE + 1800, queries: 40, blocked: 0, cached: 0 },
|
|
],
|
|
coverage: COVERAGE,
|
|
};
|
|
|
|
const CLIENTS: StatsClients = {
|
|
period: "24h",
|
|
since: SINCE,
|
|
until: UNTIL,
|
|
bucket_seconds: 1800,
|
|
clients: [
|
|
{ client: "192.0.2.30", buckets: [40, 20] },
|
|
{ client: "192.0.2.31", buckets: [20, 20] },
|
|
],
|
|
other: [0, 0],
|
|
coverage: COVERAGE,
|
|
};
|
|
|
|
const TYPES: StatsTypes = {
|
|
period: "24h",
|
|
since: SINCE,
|
|
until: UNTIL,
|
|
types: [
|
|
{ qtype: 1, count: 600 },
|
|
{ qtype: 28, count: 300 },
|
|
{ qtype: null, count: 100 },
|
|
],
|
|
coverage: COVERAGE,
|
|
};
|
|
|
|
const ROUTES: StatsRoutes = {
|
|
period: "24h",
|
|
since: SINCE,
|
|
until: UNTIL,
|
|
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 shapes an hour wide, so a period change is observable in every panel. */
|
|
const HOUR = {
|
|
totals: { ...TOTALS, period: "1h", since: UNTIL - 3600, queries: 12, blocked: 3, clients: 2 } as StatsTotals,
|
|
timeseries: { ...SERIES, period: "1h", since: UNTIL - 3600, bucket_seconds: 60, buckets: [] } as StatsTimeseries,
|
|
clients: { ...CLIENTS, period: "1h", since: UNTIL - 3600, clients: [], other: [] } as StatsClients,
|
|
types: { ...TYPES, period: "1h", since: UNTIL - 3600, types: [] } as StatsTypes,
|
|
routes: { ...ROUTES, period: "1h", since: UNTIL - 3600, routes: [] } as StatsRoutes,
|
|
};
|
|
|
|
let healthBody: Health;
|
|
let failing: Set<string>;
|
|
/** The registered clients, as `/api/clients` answers them. */
|
|
let registered: { ip: string; name: string; learned_name: string }[];
|
|
let coverageComplete: boolean;
|
|
/** Paths held in flight, so a test can look at the page while one is pending. */
|
|
let delayed: Map<string, Promise<void>>;
|
|
|
|
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 = new Set();
|
|
registered = [];
|
|
coverageComplete = true;
|
|
delayed = new Map();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
const hour = url.includes("period=1h");
|
|
for (const [path, body] of [
|
|
["/api/stats/timeseries", hour ? HOUR.timeseries : SERIES],
|
|
["/api/stats/clients", hour ? HOUR.clients : CLIENTS],
|
|
["/api/stats/types", hour ? HOUR.types : TYPES],
|
|
["/api/stats/routes", hour ? HOUR.routes : ROUTES],
|
|
["/api/stats", hour ? HOUR.totals : TOTALS],
|
|
] as const) {
|
|
if (!url.startsWith(path)) continue;
|
|
if (failing.has(path)) return json({ error: "endpoint unavailable" }, 400);
|
|
const held = delayed.get(path);
|
|
if (held !== undefined) await held;
|
|
return json(withCoverage(body));
|
|
}
|
|
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 slow endpoint does not hold the page back: the panels that answered render beside it", async () => {
|
|
// Through the real route, which is the point: the loader starts the five
|
|
// requests and awaits none of them. If it awaited, the router would hold the
|
|
// whole page until the slowest answered and this would time out on the tiles.
|
|
let release = () => {};
|
|
delayed.set("/api/stats/routes", new Promise<void>((resolve) => (release = resolve)));
|
|
|
|
renderApp();
|
|
|
|
// The tiles and both charts are readable while the routes request is still
|
|
// in flight, and the panel waiting on it says so for itself.
|
|
await screen.findByText("1,000");
|
|
expect(within(panel("Queries over time")).getAllByText("Blocked").length).toBeGreaterThan(0);
|
|
expect(within(panel("Client activity over time")).getAllByText("192.0.2.30")).toHaveLength(2);
|
|
expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2);
|
|
expect(within(panel("Upstream servers")).getByRole("status").textContent).toBe("Loading…");
|
|
|
|
release();
|
|
await waitFor(() => expect(within(panel("Upstream servers")).queryByRole("status")).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);
|
|
});
|
|
|
|
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");
|
|
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
|
|
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
|
|
});
|
|
|
|
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("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true");
|
|
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("button", { 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("one failing panel keeps its own error and leaves the rest of the page standing", async () => {
|
|
failing.add("/api/stats/routes");
|
|
renderApp();
|
|
await screen.findByText("1,000");
|
|
|
|
await waitFor(() => expect(within(panel("Upstream servers")).getByText("endpoint unavailable")).toBeTruthy());
|
|
expect(within(panel("Upstream servers")).getByRole("button", { name: "Retry" })).toBeTruthy();
|
|
// A failed donut never blanks the charts.
|
|
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
|
expect(screen.getByRole("img", { name: /client activity over time/i })).toBeTruthy();
|
|
expect(screen.queryByText("Something went wrong")).toBeNull();
|
|
});
|
|
|
|
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);
|
|
});
|