admin: draw the overview charts with visx

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.
This commit is contained in:
2026-08-24 18:40:17 +02:00
parent 08d756cc87
commit ad26aca198
29 changed files with 2940 additions and 886 deletions
@@ -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, seriesColor } from "./seriesColors";
import { clientKey, qtypeKey, seriesColor } from "./seriesColors";
import { health } from "@/lib/healthFixture";
import type { Health, StatsClients, StatsRoutes, StatsTimeseries, StatsTotals, StatsTypes } from "@/lib/types";
@@ -203,6 +203,18 @@ 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 () => {
// `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
@@ -259,9 +271,10 @@ test("naming a client does not recolour its series", async () => {
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.
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" });
@@ -269,8 +282,8 @@ test("the client chart names Other even in a period where it counted nothing", a
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);
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 () => {