overview: one endpoint, live projections and a response cache (m36)
Gates / frontend (push) Successful in 2m6s
Gates / test (push) Successful in 2m57s
Gates / test-aarch64 (push) Successful in 8m31s
Gates / package (push) Successful in 4m19s
Gates / container (push) Failing after 2s
CI / gates (push) Failing after 26m21s

This commit is contained in:
2026-08-27 17:48:20 +02:00
parent a261dc2aa3
commit 6a0630c288
40 changed files with 3298 additions and 2242 deletions
@@ -99,14 +99,17 @@ test("a failed status is announced by the shell on a page that is not configurat
stubApi(DATABASE, {
responses: {
"GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }),
"GET /api/stats?period=24h": {
"GET /api/overview?period=24h": {
period: "24h",
since: 0,
until: 86400,
queries: 0,
blocked: 0,
clients: 0,
avg_response_time_us: null,
bucket_seconds: 1800,
totals: { queries: 0, blocked: 0, clients: 0, avg_response_time_us: null },
buckets: [],
clients: [],
other: [],
types: [],
routes: [],
coverage: { complete: true, available_since: 0 },
},
},
@@ -2,23 +2,14 @@ import { fireEvent, render as renderBare, screen, within } from "@testing-librar
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import { formatTime } from "@/lib/format";
import type { StatsClients } from "@/lib/types";
import ClientChart from "./ClientChart";
import ClientChart, { type ClientChartData } from "./ClientChart";
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
const SINCE = 1_700_000_000;
const BUCKET = 1800;
function clients(named: { client: string; buckets: number[] }[], other: number[]): StatsClients {
return {
period: "24h",
since: SINCE,
until: SINCE + other.length * BUCKET,
bucket_seconds: BUCKET,
coverage: { complete: true, available_since: SINCE },
clients: named,
other,
};
function clients(named: { client: string; buckets: number[] }[], other: number[]): ClientChartData {
return { since: SINCE, bucket_seconds: BUCKET, clients: named, other };
}
const TWO_BUCKETS = clients(
@@ -34,15 +25,15 @@ const TWO_BUCKETS = clients(
* client is registered in these fixtures, which is what leaves the addresses on
* screen as the labels.
*/
function render(data: StatsClients) {
function render(data: ClientChartData) {
const client = createQueryClient();
const tree = (next: StatsClients) => (
const tree = (next: ClientChartData) => (
<QueryClientProvider client={client}>
<ClientChart data={next} />
</QueryClientProvider>
);
const result = renderBare(tree(data));
return { ...result, rerender: (next: StatsClients) => result.rerender(tree(next)) };
return { ...result, rerender: (next: ClientChartData) => result.rerender(tree(next)) };
}
beforeEach(() => {
+14 -3
View File
@@ -13,7 +13,7 @@ import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { BarStack } from "@visx/shape";
import { formatTime } from "@/lib/format";
import type { StatsClients } from "@/lib/types";
import type { OverviewClientSeries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
@@ -82,7 +82,7 @@ interface Series {
* and a table column all saying zero. The named clients stay at zero, because a
* client that went quiet is something the reader wants to see.
*/
function seriesOf(data: StatsClients, names: ClientNames): Series[] {
function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
const named = data.clients.map((client) => ({
key: clientKey(client.client),
// The name if the client is registered under one, the address otherwise —
@@ -100,10 +100,21 @@ function seriesOf(data: StatsClients, names: ClientNames): Series[] {
];
}
/**
* The slice of the Overview body this chart draws. Declared here rather than
* taken whole, so what the chart reads is stated where it is read.
*/
export interface ClientChartData {
since: number;
bucket_seconds: number;
clients: OverviewClientSeries[];
other: number[];
}
/** One column: the timestamp plus one entry per series, keyed by the series key. */
type Column = { ts: number } & Record<string, number>;
export default function ClientChart({ data }: { data: StatsClients }) {
export default function ClientChart({ data }: { data: ClientChartData }) {
const [containerRef, width] = useMeasuredWidth();
// A hover survives a re-render only while it still names the same bucket at
// the same place: a poll that rolls the window, or a resize, retires it.
@@ -0,0 +1,133 @@
/**
* The part of Overview that does not wait for anything: the heading, the period
* picker, and the pulsing body the page shows while the window is in flight.
*
* It lives apart from `OverviewPage` so the route's pending component can render
* the identical surface while the page chunk loads. Importing the page itself
* would pull the charts into the main bundle, and a second hand-written copy of
* the frame would drift. Nothing here imports a chart.
*/
import * as stylex from "@stylexjs/stylex";
import { useNavigate, useSearch } from "@tanstack/react-router";
import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { DEFAULT_PERIOD, PERIODS } from "./period";
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
headingRow: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
period: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
loading: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
{...stylex.props(
styles.period,
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
>
{option}
</button>
))}
</div>
);
}
export function OverviewLoading() {
return (
<p role="status" {...stylex.props(styles.loading, shared.pulse)}>
Loading
</p>
);
}
export function OverviewFrame({
period,
onChange,
children,
}: {
period: Period;
onChange: (period: Period) => void;
children: React.ReactNode;
}) {
return (
<div {...stylex.props(styles.page)}>
<div {...stylex.props(styles.headingRow)}>
<h1 {...stylex.props(styles.heading)}>Overview</h1>
<PeriodPicker period={period} onChange={onChange} />
</div>
{children}
</div>
);
}
/**
* The route's pending surface. The picker stays live because it only writes the
* search parameter, which the route already re-reads on its own.
*/
export function OverviewPending() {
const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
const navigate = useNavigate({ from: "/overview" });
return (
<OverviewFrame
period={period}
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
>
<OverviewLoading />
</OverviewFrame>
);
}
@@ -16,64 +16,32 @@ 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";
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 TOTALS: StatsTotals = {
period: "24h",
since: SINCE,
until: UNTIL,
queries: 1000,
blocked: 250,
clients: 7,
avg_response_time_us: 2345,
coverage: COVERAGE,
};
const SERIES: StatsTimeseries = {
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 },
],
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 },
@@ -83,22 +51,27 @@ const ROUTES: StatsRoutes = {
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,
/** 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: Set<string>;
let failing: boolean;
/** 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>>;
/** 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" } });
@@ -110,27 +83,18 @@ function withCoverage<T extends { coverage: typeof COVERAGE }>(body: T): T {
beforeEach(() => {
healthBody = health();
failing = new Set();
failing = false;
registered = [];
coverageComplete = true;
delayed = new Map();
delayed = null;
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.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({
@@ -215,25 +179,27 @@ test("the page builds a donut slice's colour from the entry's identity", async (
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.
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.set("/api/stats/routes", new Promise<void>((resolve) => (release = resolve)));
delayed = 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…");
await screen.findByRole("heading", { name: "Overview", level: 1 });
expect(screen.getByRole("button", { 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();
await waitFor(() => expect(within(panel("Upstream servers")).queryByRole("status")).toBeNull());
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 () => {
@@ -392,17 +358,24 @@ test("the picker rescopes every panel and writes the period into the url", async
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");
test("a failed request is one error for the whole page, stated once and retryable", async () => {
failing = true;
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();
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("button", { 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 () => {
+59 -140
View File
@@ -8,9 +8,9 @@
* The period is URL state, so a view is a link: `/overview?period=1h` opens
* exactly what the sender was reading.
*
* Every panel reads the same window (`overviewWindow.ts`) and renders on its
* own. A donut whose request failed shows its own error while the charts keep
* their data, and no two panels ever describe different spans.
* One request feeds every panel (`overviewWindow.ts`), so the page has one
* loading state and one error state rather than six: there is no longer a
* partial answer to render, and nothing left for a panel to disagree about.
*/
import * as stylex from "@stylexjs/stylex";
@@ -18,16 +18,16 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { qtypeName } from "@/features/provenance/qtype";
import type { Period, StatsRoutes, StatsTypes } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import type { Overview, OverviewRouteRow, OverviewTypeRow } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
import ClientChart from "./ClientChart";
import Donut from "./Donut";
import StatTiles from "./StatTiles";
import TimeseriesChart from "./TimeseriesChart";
import type { DonutSlice } from "./Donut";
import { OverviewFrame, OverviewLoading } from "./OverviewFrame";
import { useOverviewWindow, type Panel } from "./overviewWindow";
import { DEFAULT_PERIOD, PERIODS } from "./period";
import { DEFAULT_PERIOD } from "./period";
import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
/**
@@ -48,48 +48,6 @@ const ROUTE_LABELS = {
} as const;
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
headingRow: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
period: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
@@ -110,54 +68,20 @@ const styles = stylex.create({
gap: "1rem",
gridTemplateColumns: { default: "minmax(0, 1fr)", [TWO_COLUMN]: "repeat(2, minmax(0, 1fr))" },
},
loading: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
{...stylex.props(
styles.period,
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
>
{option}
</button>
))}
</div>
);
}
/**
* One panel's three states. Loading and error are the panel's own: a failure
* here never reaches past this box, which is what keeps a failed donut from
* blanking the charts beside it.
* The page's three states. The heading and the period picker stay put through
* all three, so the reader can rescope or retry without waiting for anything.
*/
function PanelBody<T>({ panel, children }: { panel: Panel<T>; children: (data: T) => React.ReactNode }) {
function PageBody({ panel, children }: { panel: Panel<Overview>; children: (data: Overview) => React.ReactNode }) {
if (panel.status === "error") return <InlineError error={panel.error} onRetry={panel.retry} />;
if (panel.status === "loading") {
return (
<p role="status" {...stylex.props(styles.loading, shared.pulse)}>
Loading
</p>
);
}
if (panel.status === "loading") return <OverviewLoading />;
return <>{children(panel.data)}</>;
}
function typeSlices(data: StatsTypes): DonutSlice[] {
return data.types.map((row) => ({
function typeSlices(types: OverviewTypeRow[]): DonutSlice[] {
return types.map((row) => ({
key: qtypeKey(row.qtype),
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
value: row.count,
@@ -171,8 +95,8 @@ function typeSlices(data: StatsTypes): DonutSlice[] {
* appear under two kinds and two rows can both be "Unknown". The four
* source-less kinds are their own label and need no qualifier.
*/
function routeSlices(data: StatsRoutes): DonutSlice[] {
return data.routes.map((row) => {
function routeSlices(routes: OverviewRouteRow[]): DonutSlice[] {
return routes.map((row) => {
const named = row.route === "upstream" || row.route === "forward_zone";
return {
key: routeKey(row.route, row.source),
@@ -190,59 +114,54 @@ export default function OverviewPage() {
const overview = useOverviewWindow(period);
return (
<div {...stylex.props(styles.page)}>
<div {...stylex.props(styles.headingRow)}>
<h1 {...stylex.props(styles.heading)}>Overview</h1>
<PeriodPicker
period={period}
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
/>
</div>
<OverviewFrame
period={period}
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
>
<PageBody panel={overview}>
{(data) => (
<>
<StatTiles stats={{ since: data.since, until: data.until, ...data.totals }} />
<PanelBody panel={overview.totals}>{(totals) => <StatTiles stats={totals} />}</PanelBody>
{/* One notice for the page: every panel came out of this one
response, so a second copy would only repeat this sentence. */}
<CoverageNotice coverage={data.coverage} />
{/* One notice for the page: every panel is judged against the same window,
so a second copy would only repeat this sentence. */}
{overview.coverage !== null && <CoverageNotice coverage={overview.coverage} />}
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
Queries over time
</h2>
<TimeseriesChart data={data} />
</section>
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
Queries over time
</h2>
<PanelBody panel={overview.timeseries}>{(data) => <TimeseriesChart data={data} />}</PanelBody>
</section>
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
Client activity over time
</h2>
<ClientChart data={data} />
</section>
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
Client activity over time
</h2>
<PanelBody panel={overview.clients}>{(data) => <ClientChart data={data} />}</PanelBody>
</section>
<div {...stylex.props(styles.donutRow)}>
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
Query types
</h2>
<PanelBody panel={overview.types}>
{(data) => <Donut slices={typeSlices(data)} caption="Queries by DNS type" unit="Queries" />}
</PanelBody>
</section>
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
Upstream servers
</h2>
<PanelBody panel={overview.routes}>
{(data) => (
<Donut
slices={routeSlices(data)}
caption="Queries by how they were answered"
unit="Queries"
/>
)}
</PanelBody>
</section>
</div>
</div>
<div {...stylex.props(styles.donutRow)}>
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
Query types
</h2>
<Donut slices={typeSlices(data.types)} caption="Queries by DNS type" unit="Queries" />
</section>
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
Upstream servers
</h2>
<Donut
slices={routeSlices(data.routes)}
caption="Queries by how they were answered"
unit="Queries"
/>
</section>
</div>
</>
)}
</PageBody>
</OverviewFrame>
);
}
+9 -3
View File
@@ -5,7 +5,7 @@
* typographic, so the eye ranks the figures rather than the panels, and a tile
* never implies a state it is not reporting.
*
* The Activity links carry the bounds the **stats response** returned, not
* The Activity links carry the bounds the **overview response** returned, not
* bounds computed here — a client-computed window would send the reader to a
* slightly different span than the one they were just reading.
*/
@@ -13,7 +13,7 @@
import * as stylex from "@stylexjs/stylex";
import { Link } from "@tanstack/react-router";
import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types";
import type { OverviewTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -101,7 +101,13 @@ function Tile({
);
}
export default function StatTiles({ stats }: { stats: StatsTotals }) {
/** The window's totals with the bounds they were measured over. */
export interface StatTilesData extends OverviewTotals {
since: number;
until: number;
}
export default function StatTiles({ stats }: { stats: StatTilesData }) {
const window = {
mode: "history" as const,
since: stats.since,
@@ -1,24 +1,17 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { Bucket, StatsTimeseries } from "@/lib/types";
import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import TimeseriesChart from "./TimeseriesChart";
import TimeseriesChart, { type TimeseriesData } from "./TimeseriesChart";
const SINCE = 1_700_000_000;
function timeseries(buckets: Bucket[]): StatsTimeseries {
return {
period: "24h",
since: SINCE,
until: SINCE + buckets.length * 1800,
bucket_seconds: 1800,
coverage: { complete: true, available_since: SINCE },
buckets,
};
function timeseries(buckets: Bucket[]): TimeseriesData {
return { since: SINCE, bucket_seconds: 1800, buckets };
}
function counting(bucketCount: number): StatsTimeseries {
function counting(bucketCount: number): TimeseriesData {
return timeseries(
Array.from({ length: bucketCount }, (_, i) => ({
ts: SINCE + i * 1800,
@@ -2,7 +2,7 @@ import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { BarStack } from "@visx/shape";
import { formatTime } from "@/lib/format";
import type { Bucket, StatsTimeseries } from "@/lib/types";
import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import {
@@ -103,7 +103,17 @@ function tooltipOf(column: Column): TooltipContent {
};
}
export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
/**
* The slice of the Overview body this chart draws. Declared here rather than
* taken whole, so what the chart reads is stated where it is read.
*/
export interface TimeseriesData {
since: number;
bucket_seconds: number;
buckets: Bucket[];
}
export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
const [containerRef, width] = useMeasuredWidth();
// A hover survives a re-render only while it still names the same bucket at
// the same place: a poll that rolls the window, or a resize, retires it.
@@ -1,74 +1,41 @@
/**
* Window coherence across the five Overview requests, migrated from the
* two-request `activityWindow` this replaces. Every behaviour that hook pinned
* is pinned here — the identity, the one retry per mismatch episode, the
* terminal error, the discarded previous-period pair and the stale completion
* that must not speak — now over five endpoints and with the watermark in the
* identity, plus the per-panel isolation the layout added.
* The hook over the single `/api/overview` request.
*
* The five-endpoint build reconciled five window identities here — the retry per
* mismatch episode, the terminal "different window" error, the orphaned stale
* completion. One request cannot disagree with itself, so those behaviours have
* no subject left and are gone rather than ported. What survived the collapse is
* pinned below: the three states, and the one rule a single request still does
* not settle — that a `keepPreviousData` body from the period the reader left
* must never render under the new period's label.
*/
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import type { Coverage, Period } from "@/lib/types";
import {
newerWindow,
sameWindow,
useOverviewWindow,
windowIdOf,
OVERVIEW_ENDPOINTS,
type OverviewEndpoint,
} from "./overviewWindow";
import type { Period } from "@/lib/types";
import { useOverviewWindow } from "./overviewWindow";
const SINCE = Date.UTC(2026, 0, 1, 0, 0) / 1000;
const UNTIL = Date.UTC(2026, 0, 2, 0, 0) / 1000;
const COVERAGE: Coverage = { complete: true, available_since: SINCE };
/** Where each endpoint's body currently ends, and what watermark it admits. */
interface Bounds {
until: number;
availableSince: number;
}
let failing: boolean;
let calls: number;
const PATHS: Record<OverviewEndpoint, string> = {
totals: "/api/stats?period=",
timeseries: "/api/stats/timeseries?period=",
clients: "/api/stats/clients?period=",
types: "/api/stats/types?period=",
routes: "/api/stats/routes?period=",
};
let bounds: Record<OverviewEndpoint, Bounds>;
let failing: Set<OverviewEndpoint>;
let calls: Record<OverviewEndpoint, number>;
/** Endpoints that answer for the page's window from their second call onward. */
let catchUp: Set<OverviewEndpoint>;
/** Held to keep one answer in flight while the test moves the page on. */
let hold: { promise: Promise<void>; release: () => void } | null;
function endpointOf(url: string): OverviewEndpoint | null {
// Longest prefix first: `/api/stats?` and `/api/stats/…` share a stem.
for (const endpoint of ["timeseries", "clients", "types", "routes", "totals"] as const) {
if (url.startsWith(PATHS[endpoint])) return endpoint;
}
return null;
}
function body(endpoint: OverviewEndpoint, period: Period): unknown {
const { until, availableSince } = bounds[endpoint];
const shared = { period, since: SINCE, until, coverage: { ...COVERAGE, available_since: availableSince } };
switch (endpoint) {
case "totals":
return { ...shared, queries: 10, blocked: 2, clients: 1, avg_response_time_us: 1000 };
case "timeseries":
return { ...shared, bucket_seconds: 3600, buckets: [] };
case "clients":
return { ...shared, bucket_seconds: 3600, clients: [], other: [] };
case "types":
return { ...shared, types: [] };
case "routes":
return { ...shared, routes: [] };
}
function body(period: Period): unknown {
return {
period,
since: SINCE,
until: UNTIL,
bucket_seconds: 1800,
totals: { queries: 10, blocked: 2, clients: 1, avg_response_time_us: 1000 },
buckets: [],
clients: [],
other: [],
types: [],
routes: [],
coverage: { complete: true, available_since: SINCE },
};
}
function json(payload: unknown, status = 200): Response {
@@ -76,55 +43,36 @@ function json(payload: unknown, status = 200): Response {
}
beforeEach(() => {
bounds = {
totals: { until: UNTIL, availableSince: SINCE },
timeseries: { until: UNTIL, availableSince: SINCE },
clients: { until: UNTIL, availableSince: SINCE },
types: { until: UNTIL, availableSince: SINCE },
routes: { until: UNTIL, availableSince: SINCE },
};
failing = new Set();
catchUp = new Set();
hold = null;
calls = { totals: 0, timeseries: 0, clients: 0, types: 0, routes: 0 };
failing = false;
calls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const endpoint = endpointOf(url);
if (endpoint === null) return json({ error: "not stubbed" }, 404);
calls[endpoint] += 1;
if (failing.has(endpoint)) return json({ error: "endpoint unavailable" }, 400);
if (catchUp.has(endpoint) && calls[endpoint] >= 2)
bounds[endpoint] = { until: UNTIL, availableSince: SINCE };
if (!url.startsWith("/api/overview")) return json({ error: "not stubbed" }, 404);
calls += 1;
if (failing) return json({ error: "endpoint unavailable" }, 400);
const period = (new URLSearchParams(url.split("?")[1]).get("period") ?? "24h") as Period;
// Built before the wait, so a held answer carries what its own request
// would have returned rather than what the page has moved on to.
const payload = json(body(endpoint, period));
if (hold !== null && endpoint === "routes" && calls.routes === 2) await hold.promise;
return payload;
return json(body(period));
}),
);
});
afterEach(() => vi.unstubAllGlobals());
/** The last retry the hook handed out, so a test can spend it. */
let lastRetry: () => void;
function Probe({ period }: { period: Period }) {
const overview = useOverviewWindow(period);
return (
<ul>
{OVERVIEW_ENDPOINTS.map((endpoint) => {
const panel = overview[endpoint];
const detail =
panel.status === "ready"
? `${panel.data.period}@${panel.data.until}/${panel.data.coverage.available_since}`
: panel.status === "error"
? (panel.error as Error).message
: "";
return <li key={endpoint}>{`${endpoint}:${panel.status}:${detail}`}</li>;
})}
</ul>
);
const panel = useOverviewWindow(period);
if (panel.status === "error") lastRetry = panel.retry;
const detail =
panel.status === "ready"
? `${panel.data.period}@${panel.data.until}`
: panel.status === "error"
? (panel.error as Error).message
: "";
return <p>{`${panel.status}:${detail}`}</p>;
}
function renderProbe(period: Period = "24h") {
@@ -144,149 +92,36 @@ function renderProbe(period: Period = "24h") {
};
}
function line(endpoint: OverviewEndpoint): string {
const item = screen.getAllByRole("listitem").find((element) => element.textContent?.startsWith(`${endpoint}:`));
if (item === undefined) throw new Error(`no probe line for ${endpoint}`);
return item.textContent ?? "";
function line(): string {
return screen.getByRole("paragraph").textContent ?? "";
}
test("the window identity is the period, both bounds and the watermark together", () => {
const base = { period: "24h" as const, since: SINCE, until: UNTIL, availableSince: SINCE };
expect(sameWindow(base, { ...base })).toBe(true);
expect(sameWindow(base, { ...base, period: "1h" })).toBe(false);
expect(sameWindow(base, { ...base, since: SINCE - 1 })).toBe(false);
expect(sameWindow(base, { ...base, until: UNTIL + 1 })).toBe(false);
// The bounds agree and the answers still describe different windows: a prune
// between the two requests moved what the same span can be answered for.
expect(sameWindow(base, { ...base, availableSince: SINCE + 60 })).toBe(false);
});
test("the newer until wins, and for equal bounds the later watermark does", () => {
const base = { period: "24h" as const, since: SINCE, until: UNTIL, availableSince: SINCE };
expect(newerWindow(base, { ...base, until: UNTIL + 60 }).until).toBe(UNTIL + 60);
expect(newerWindow({ ...base, until: UNTIL + 60 }, base).until).toBe(UNTIL + 60);
expect(newerWindow(base, { ...base, availableSince: SINCE + 60 }).availableSince).toBe(SINCE + 60);
// A newer watermark does not outrank an older window's later bound.
expect(newerWindow({ ...base, until: UNTIL + 60 }, { ...base, availableSince: SINCE + 60 }).until).toBe(UNTIL + 60);
});
test("windowIdOf reads the four fields off any of the five bodies", () => {
expect(windowIdOf({ period: "7d", since: 1, until: 2, coverage: { complete: false, available_since: 3 } })).toEqual(
{
period: "7d",
since: 1,
until: 2,
availableSince: 3,
},
);
});
test("five responses for one window render as five ready panels", async () => {
test("the page is loading until the body for the selected period arrives", async () => {
renderProbe();
await waitFor(() => expect(line("totals")).toContain("ready"));
for (const endpoint of OVERVIEW_ENDPOINTS) {
expect(line(endpoint)).toBe(`${endpoint}:ready:24h@${UNTIL}/${SINCE}`);
}
expect(line()).toBe("loading:");
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
});
test("one endpoint behind a bucket boundary is refetched once and then agrees", async () => {
// Behind on its first answer, caught up by the time the hook asks again.
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
catchUp.add("routes");
test("a failed request is one error for the whole page, with a retry that refetches", async () => {
failing = true;
renderProbe();
await waitFor(() => expect(line("routes")).toContain("ready"));
expect(calls.routes).toBe(2);
expect(calls.totals).toBe(1);
});
await waitFor(() => expect(line()).toBe("error:endpoint unavailable"));
const spent = calls;
test("a laggard that stays behind fails its own panel and leaves the rest rendering", async () => {
bounds.types = { until: UNTIL - 3600, availableSince: SINCE };
renderProbe();
await waitFor(() => expect(line("types")).toContain("error"));
expect(line("types")).toContain("different window");
// One retry, not a loop.
expect(calls.types).toBe(2);
for (const endpoint of ["totals", "timeseries", "clients", "routes"] as const) {
expect(line(endpoint)).toContain("ready");
}
});
test("a failed request degrades its own panel; the charts keep the window", async () => {
failing.add("routes");
renderProbe();
await waitFor(() => expect(line("routes")).toContain("error"));
expect(line("routes")).toContain("endpoint unavailable");
expect(line("timeseries")).toContain("ready");
expect(line("totals")).toContain("ready");
});
test("a watermark that advanced mid-page is a mismatch, not a mixed window", async () => {
// Same bounds, later watermark: retention pruned between the two responses.
bounds.clients = { until: UNTIL, availableSince: SINCE + 600 };
renderProbe();
await waitFor(() => expect(line("clients")).toContain(`/${SINCE + 600}`));
// The page adopts the later watermark, so the four older answers are the
// laggards and each gets its one retry rather than rendering beside it.
await waitFor(() => expect(calls.totals).toBe(2));
expect(line("clients")).toContain("ready");
failing = false;
lastRetry();
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
expect(calls).toBeGreaterThan(spent);
});
test("a retained previous-period body never renders under the new period's label", async () => {
const { rerenderWith } = renderProbe("24h");
await waitFor(() => expect(line("totals")).toBe(`totals:ready:24h@${UNTIL}/${SINCE}`));
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
rerenderWith("1h");
// Whatever `keepPreviousData` is holding, no panel may claim it answers 1h.
await waitFor(() => expect(line("totals")).toBe(`totals:ready:1h@${UNTIL}/${SINCE}`));
for (const endpoint of OVERVIEW_ENDPOINTS) expect(line(endpoint)).toContain("1h@");
});
test("a period change buys the new window its own retry", async () => {
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
const { rerenderWith } = renderProbe("24h");
await waitFor(() => expect(line("routes")).toContain("error"));
const spent = calls.routes;
rerenderWith("1h");
// The mismatch persists under the new period, and the episode key changed
// with it: the retry the abandoned period spent is not the new one's.
await waitFor(() => expect(calls.routes).toBeGreaterThan(spent));
await waitFor(() => expect(line("routes")).toContain("error"));
});
test("a retry in flight when the period changes cannot spend the window's retry later", async () => {
// The stale completion the tokens exist to orphan: routes lags under 24h, the
// hook issues its one retry, and the reader picks 1h before that retry lands.
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
let release = () => {};
hold = { promise: new Promise<void>((resolve) => (release = resolve)), release: () => release() };
const { rerenderWith } = renderProbe("24h");
await waitFor(() => expect(calls.routes).toBe(2));
bounds.routes = { until: UNTIL, availableSince: SINCE };
rerenderWith("1h");
await waitFor(() => expect(line("routes")).toContain("1h@"));
// The abandoned retry lands now, under a period it was never asked for.
hold.release();
hold = null;
await waitFor(() => expect(line("routes")).toContain("ready"));
// Back to the window it was issued for, still lagging. The stale completion
// must not have marked this episode spent: the panel gets a real retry before
// it is allowed to reach the terminal error.
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
rerenderWith("24h");
// The cached lagging body is there to render immediately, and the panel must
// not state the terminal error off it: that error means "retried and still
// behind", and this visit has not retried anything yet. An abandoned
// completion recording the episode as spent is what would produce it here.
expect(line("routes")).toContain("loading");
await waitFor(() => expect(line("routes")).toContain("different window"));
// `keepPreviousData` is holding the 24h body. It is a complete answer and
// still the wrong one to draw under "1h", so the page waits.
expect(line()).toBe("loading:");
await waitFor(() => expect(line()).toBe(`ready:1h@${UNTIL}`));
});
+24 -239
View File
@@ -1,249 +1,34 @@
/**
* One period, five requests, one window.
* One period, one request, one window.
*
* Totals, the timeline, the per-client series and the two breakdowns are
* separate calls, so a refresh that straddles a bucket boundary — or a retention
* pass that advances the watermark mid-page — can answer them for different
* windows. Rendering them side by side anyway would put a headline count above
* charts of a different span, a mixed page that looks exactly like a real one.
* The five per-panel endpoints this replaces could each answer for a different
* span, so the page had to reconcile five window identities, retry the laggards
* and fail the ones that stayed behind. `GET /api/overview` answers every panel
* out of a single read transaction: the totals, both timelines and both
* breakdowns describe the same span and the same database state by construction,
* and none of that reconciliation has anything left to reconcile.
*
* This is **window** coherence, not data-snapshot coherence: matching bounds
* cannot prove a common database state, and live inserts between requests may
* still shift counts slightly between panels. What it does guarantee is that no
* two panels ever describe different spans.
*
* Rendering is per panel. A panel whose request is still in flight shows its own
* loading state and a panel whose request failed shows its own error, while the
* panels that match the window keep rendering — a failed donut never blanks the
* charts.
* What remains is the one rule a single request does not settle by itself.
* `keepPreviousData` holds the body of the period the reader just left — a
* complete, self-consistent answer, and still the wrong one to draw under the
* new label — so a body is a member of this window only while its own `period`
* is the selected one. Until then the page is loading.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
import { statsClientsQuery, statsQuery, statsRoutesQuery, statsTypesQuery, timeseriesQuery } from "@/lib/queries";
import type {
Coverage,
Period,
StatsClients,
StatsRoutes,
StatsTimeseries,
StatsTotals,
StatsTypes,
} from "@/lib/types";
export const OVERVIEW_ENDPOINTS = ["totals", "timeseries", "clients", "types", "routes"] as const;
export type OverviewEndpoint = (typeof OVERVIEW_ENDPOINTS)[number];
interface EndpointBodies {
totals: StatsTotals;
timeseries: StatsTimeseries;
clients: StatsClients;
types: StatsTypes;
routes: StatsRoutes;
}
/**
* What makes two responses the same window. `available_since` joins the bounds
* because retention advancing between requests changes what the same `[since,
* until)` can answer for, and mixing a pre-prune answer with a post-prune one is
* the failure the bounds alone would not catch.
*/
export interface WindowId {
period: Period;
since: number;
until: number;
availableSince: number;
}
/** The four fields every window-bounded stats body carries. */
interface Bounded {
period: Period;
since: number;
until: number;
coverage: Coverage;
}
export function windowIdOf(body: Bounded): WindowId {
return {
period: body.period,
since: body.since,
until: body.until,
availableSince: body.coverage.available_since,
};
}
export function sameWindow(a: WindowId, b: WindowId): boolean {
return a.period === b.period && a.since === b.since && a.until === b.until && a.availableSince === b.availableSince;
}
/**
* Which of two candidate windows the page adopts: the one that reaches further
* forward in time, and for identical bounds the one that admits the later
* watermark. Both rules pick the answer a laggard has to catch up to.
*/
export function newerWindow(a: WindowId, b: WindowId): WindowId {
if (b.until !== a.until) return b.until > a.until ? b : a;
return b.availableSince > a.availableSince ? b : a;
}
function keyOf(id: WindowId): string {
return `${id.period}|${id.since}|${id.until}|${id.availableSince}`;
}
import { useCallback } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { overviewQuery } from "@/lib/queries";
import type { Overview, Period } from "@/lib/types";
export type Panel<T> =
{ status: "loading" } | { status: "error"; error: unknown; retry: () => void } | { status: "ready"; data: T };
export interface OverviewWindow {
/** Null until one response for the selected period has arrived. */
window: WindowId | null;
/** The adopted window's watermark, for the page's single coverage notice. */
coverage: Coverage | null;
totals: Panel<StatsTotals>;
timeseries: Panel<StatsTimeseries>;
clients: Panel<StatsClients>;
types: Panel<StatsTypes>;
routes: Panel<StatsRoutes>;
}
/**
* A laggard that stayed behind after its one retry. Not an `ApiError`: nothing
* failed, the endpoint simply never caught up, and `InlineError` renders the
* message verbatim.
*/
export const MISMATCH = new Error("This panel is for a different window than the rest of the page. Try again.");
export function useOverviewWindow(period: Period): OverviewWindow {
const queries: { [K in OverviewEndpoint]: UseQueryResult<EndpointBodies[K]> } = {
totals: useQuery({ ...statsQuery(period), placeholderData: keepPreviousData }),
timeseries: useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData }),
clients: useQuery({ ...statsClientsQuery(period), placeholderData: keepPreviousData }),
types: useQuery({ ...statsTypesQuery(period), placeholderData: keepPreviousData }),
routes: useQuery({ ...statsRoutesQuery(period), placeholderData: keepPreviousData }),
};
// A `keepPreviousData` placeholder for the period just left is a complete,
// self-consistent body — and still the wrong one to show under the new label,
// so it is neither a candidate for the window nor a member of it.
const answers = new Map<OverviewEndpoint, WindowId>();
for (const endpoint of OVERVIEW_ENDPOINTS) {
const data = queries[endpoint].data;
if (data !== undefined && data.period === period) answers.set(endpoint, windowIdOf(data));
}
let window: WindowId | null = null;
for (const id of answers.values()) window = window === null ? id : newerWindow(window, id);
// The effect below runs on what the responses say, not on how many times they
// arrived: a poll that returns byte-identical data must not restart the retry
// bookkeeping. The refetchers ride a ref for the same reason — TanStack hands
// back a fresh function identity on some renders, and depending on it would
// re-enter the effect with nothing changed.
const answersKey = OVERVIEW_ENDPOINTS.map((endpoint) => {
const id = answers.get(endpoint);
return id === undefined ? "" : keyOf(id);
}).join("~");
const latest = useRef({ answers, refetch: queries });
latest.current = { answers, refetch: queries };
// Which mismatch episode each endpoint has already spent its retry on, keyed
// by endpoint and window identity so a new window buys a new attempt.
const retriedFor = useRef(new Map<OverviewEndpoint, string>());
// Which retry each endpoint is waiting on. Per endpoint, because one shared
// counter would let a second endpoint's retry silence the first's completion;
// bumped on every retry issued, so a completion from a window or a period the
// page has left can neither clear an error the current one reached nor spend
// the current window's one retry.
const tokens = useRef(new Map<OverviewEndpoint, number>());
// State, not a ref: a retry that returns byte-identical data changes nothing
// else a render could see, and the panel still has to reach its error.
const [landedFor, setLandedFor] = useState(new Map<OverviewEndpoint, string>());
// Leaving a period ends every episode it opened. A retry issued for the old
// period can still be in flight, and without this its completion would land
// under the new one holding a token the map still honours: it would record an
// episode as spent, so a return to that window would reach the terminal error
// without the retry that error is supposed to follow. Bumping the tokens
// orphans those answers, and the cleared maps let the new window start clean.
const [lastPeriod, setLastPeriod] = useState(period);
if (lastPeriod !== period) {
setLastPeriod(period);
for (const endpoint of OVERVIEW_ENDPOINTS) {
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
}
retriedFor.current.clear();
setLandedFor(new Map());
}
const windowKey = window === null ? null : keyOf(window);
useEffect(() => {
if (windowKey === null) return;
for (const [endpoint, identity] of latest.current.answers) {
if (keyOf(identity) === windowKey) {
retriedFor.current.delete(endpoint);
continue;
}
const episode = `${endpoint}|${windowKey}`;
if (retriedFor.current.get(endpoint) === episode) continue;
retriedFor.current.set(endpoint, episode);
const token = (tokens.current.get(endpoint) ?? 0) + 1;
tokens.current.set(endpoint, token);
const landed = () => {
if (tokens.current.get(endpoint) !== token) return;
setLandedFor((previous) => new Map(previous).set(endpoint, episode));
};
void latest.current.refetch[endpoint].refetch().then(landed, landed);
}
}, [answersKey, windowKey]);
const retry = useCallback((endpoint: OverviewEndpoint) => {
retriedFor.current.delete(endpoint);
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
setLandedFor((previous) => {
const next = new Map(previous);
next.delete(endpoint);
return next;
});
void latest.current.refetch[endpoint].refetch();
}, []);
function panelOf<K extends OverviewEndpoint>(endpoint: K): Panel<EndpointBodies[K]> {
const query = queries[endpoint];
const onRetry = () => retry(endpoint);
if (query.isError) return { status: "error", error: query.error, retry: onRetry };
const data = query.data;
if (
data !== undefined &&
windowKey !== null &&
data.period === period &&
keyOf(windowIdOf(data)) === windowKey
) {
return { status: "ready", data };
}
if (windowKey !== null && landedFor.get(endpoint) === `${endpoint}|${windowKey}`) {
return { status: "error", error: MISMATCH, retry: onRetry };
}
return { status: "loading" };
}
const panels = {
totals: panelOf("totals"),
timeseries: panelOf("timeseries"),
clients: panelOf("clients"),
types: panelOf("types"),
routes: panelOf("routes"),
};
// The notice describes the window, so any member of it can supply the
// watermark: whichever panel arrived says the same thing about coverage.
let coverage: Coverage | null = null;
for (const endpoint of OVERVIEW_ENDPOINTS) {
const panel = panels[endpoint];
if (panel.status === "ready") {
coverage = panel.data.coverage;
break;
}
}
return { window, coverage, ...panels };
export function useOverviewWindow(period: Period): Panel<Overview> {
const query = useQuery({ ...overviewQuery(period), placeholderData: keepPreviousData });
const { refetch } = query;
const retry = useCallback(() => void refetch(), [refetch]);
if (query.isError) return { status: "error", error: query.error, retry };
if (query.data !== undefined && query.data.period === period) return { status: "ready", data: query.data };
return { status: "loading" };
}