milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* 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, 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("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 names Other even in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. Dropping it from the legend there
|
||||
// would tell the reader the two named clients were every client.
|
||||
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("Other")).toHaveLength(2));
|
||||
expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user