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

This commit is contained in:
2026-08-22 16:45:15 +02:00
parent 17422fac21
commit 648d9b4496
89 changed files with 7222 additions and 4239 deletions
@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, 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";
@@ -6,6 +6,7 @@ import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { QueryDetail } from "@/lib/types";
import { provenance } from "@/features/queries/provenanceFixture";
import { health } from "@/lib/healthFixture";
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
return { id, ...provenance(sections) };
@@ -16,6 +17,7 @@ let responses: Record<string, unknown>;
beforeEach(() => {
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
"/api/health": health(),
};
vi.stubGlobal(
"fetch",
@@ -310,3 +312,43 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
domain: "gone",
});
});
/**
* The related-actions region of a query detail. Scoped on purpose: the sidebar
* carries a Pause of its own, and this is the one that answers "this query was
* blocked and should not have been".
*/
function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" });
}
test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => {
responses["/api/queries/50"] = detail(50, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" },
});
renderDetail(50);
await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
cleanup();
responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } });
renderDetail(51);
await screen.findByRole("heading", { name: "example.com" });
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
});
test("the Pause action stays away while protection is unavailable", async () => {
responses["/api/health"] = health({ protection: { state: "unavailable", until: null } });
responses["/api/queries/52"] = detail(52, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" },
});
renderDetail(52);
await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(screen.getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
});
@@ -68,7 +68,15 @@ export default function ActivityDetailPage() {
<ProvenanceDetail
provenance={detail}
persistedId={detail.id}
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
relatedActions={
<RelatedActions
domain={domain}
client={client}
ts={time}
origin={origin}
blocked={detail.policy.action === "block"}
/>
}
/>
</section>
);
@@ -9,6 +9,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/queries/provenanceFixture";
@@ -91,6 +92,8 @@ function stubFetch(handler: (url: string) => Response | Promise<Response>) {
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
@@ -6,7 +6,7 @@
* which subtree the URL mounts, not by a prop a caller could pass.
*/
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { act, 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";
@@ -14,6 +14,7 @@ import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { Client } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { health } from "@/lib/healthFixture";
import { FakeEventSource } from "./fakeEventSource";
function client(ip: string, name: string, learnedName: string): Client {
@@ -50,6 +51,8 @@ function stubFetch(handler: (url: string) => Response | Promise<Response> = () =
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
if (url === "/api/clients") return Promise.resolve(json({ clients: CLIENTS }));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
@@ -180,6 +183,8 @@ test("rows stream in as bare IPs while the client list is still loading", async
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return new Promise<Response>((resolve) => {
if (url !== "/api/clients") {
resolve(json({}));
@@ -397,3 +402,37 @@ test("leaving live closes the stream, and coming back opens exactly one fresh on
expect(sources).toHaveLength(2);
expect(sources[1]!.closed).toBe(false);
});
/**
* The related-actions region of a query detail. Scoped on purpose: the sidebar
* carries a Pause of its own, and this is the one that answers "this query was
* blocked and should not have been".
*/
function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" });
}
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => {
await openLive();
act(() =>
sources[0]!.emit(
"query",
frame(1000, "streamed.example", {
policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" },
route: { kind: "blocked", upstream: "" },
}),
),
);
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
});
test("a streamed row that was allowed offers nothing to pause", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1001, "allowed.example", { policy: { action: "allow" } })));
fireEvent.click(screen.getByRole("button", { name: "allowed.example" }));
await screen.findByRole("heading", { level: 1, name: "allowed.example" });
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
});
@@ -268,6 +268,7 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
client={summary.client_ip}
ts={summary.ts}
origin={origin}
blocked={row.event.policy.action === "block"}
/>
}
/>
@@ -274,8 +274,13 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
</Section>
</div>
<div {...stylex.props(styles.related)}>
<h2 {...stylex.props(styles.relatedHeading)}>Related</h2>
{/* A named region, because the Pause it may offer is not the only Pause
on screen: the sidebar carries one too, and the two answer different
questions. */}
<section aria-labelledby="related-actions" {...stylex.props(styles.related)}>
<h2 id="related-actions" {...stylex.props(styles.relatedHeading)}>
Related
</h2>
<p {...stylex.props(styles.relatedNote)}>
These read the current configuration, which may no longer be the one that decided this query.
</p>
@@ -294,7 +299,7 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
</p>
)}
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
</div>
</section>
</>
);
}
@@ -11,6 +11,7 @@
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import PauseControl from "@/features/pause/PauseControl";
import { styles as shared } from "@/ui/styles";
import { provenanceRelatedLink } from "./ProvenanceDetail";
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
@@ -23,9 +24,15 @@ interface Props {
ts: number;
/** The Activity search the reader came from; its bounds win over the defaults. */
origin: Pick<ActivitySearch, "since" | "until">;
/**
* This query was blocked. Pausing is a valid answer to a block the reader
* disagrees with, and to nothing else here — so the control appears for a
* block and not beside an allowed query it could not have caused.
*/
blocked: boolean;
}
export default function RelatedActions({ domain, client, ts, origin }: Props) {
export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) {
const bounds = relatedBounds(ts, origin);
const window = diagnosticsBounds(ts);
return (
@@ -54,6 +61,7 @@ export default function RelatedActions({ domain, client, ts, origin }: Props) {
>
Diagnostics around this query
</Link>
{blocked && <PauseControl />}
</>
);
}
@@ -1,252 +0,0 @@
import { fireEvent, render, screen } 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";
/** Wall clock at import; the upstream fixtures date their failures against it. */
const NOW_S = Math.floor(Date.now() / 1000);
const RESPONSES: Record<string, unknown> = {
"/api/stats?period=24h": {
period: "24h",
since: 0,
until: 86400,
queries: 1000,
blocked: 250,
cached: 100,
clients: 7,
avg_response_time_us: 2345,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=24h": {
period: "24h",
since: 0,
until: 86400,
bucket_seconds: 1800,
buckets: [
{ ts: 0, queries: 60, blocked: 20, cached: 10 },
{ ts: 1800, queries: 40, blocked: 0, cached: 0 },
{ ts: 3600, queries: 0, blocked: 0, cached: 0 },
],
coverage: { complete: true, available_since: 0 },
},
"/api/stats?period=1h": {
period: "1h",
since: 0,
until: 3600,
queries: 12,
blocked: 3,
cached: 0,
clients: 2,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=1h": {
period: "1h",
since: 0,
until: 3600,
bucket_seconds: 60,
buckets: [],
coverage: { complete: true, available_since: 0 },
},
"/api/health": {
status: "degraded",
disk: {
state: "warn",
free_bytes: 400 * 1024 * 1024,
db_bytes: 12 * 1024 * 1024,
log_bytes: 2048,
sample_failures: 0,
},
upstreams: { available: 1, total: 2 },
queries_dropped: 5,
writer_failed: false,
refreshes_gated: 0,
snapshot_generation: 3,
diagnostics: { state: "recording", active_warnings: 1, active_errors: 0 },
},
"/api/upstream/health?period=24h": {
period: "24h",
since: NOW_S - 86_400,
until: NOW_S,
available: 1,
total: 2,
complete: true,
upstreams: [
{
url: "https://dns.example/dns-query",
enabled: true,
available: false,
period: {
attempts: 100,
successes: 90,
failures: 10,
success_rate: 0.9,
// 3h30m before the fixture's now, far from a unit boundary.
last_failure_at: NOW_S - 12_600,
last_failure_error: "timeout",
},
},
{
url: "udp://9.9.9.9:53",
enabled: true,
available: true,
period: {
attempts: 100,
successes: 100,
failures: 0,
success_rate: 1,
last_failure_at: null,
last_failure_error: null,
},
},
],
},
"/api/upstream/health?period=1h": {
period: "1h",
since: NOW_S - 3600,
until: NOW_S,
available: 1,
total: 1,
complete: true,
upstreams: [
{
url: "https://dns.example/dns-query",
enabled: true,
available: true,
period: {
attempts: 7,
successes: 6,
failures: 1,
success_rate: 6 / 7,
last_failure_at: NOW_S - 300,
last_failure_error: "timeout",
},
},
],
},
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
// Endpoints forced to fail with a 4xx, which the query client does not retry.
let failing: Set<string>;
beforeEach(() => {
failing = new Set();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (failing.has(url)) {
return new Response(JSON.stringify({ error: "upstream health unavailable" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderDashboard() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
}
test("dashboard renders stats, chart, disk card, upstream table and health banners", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByText("250")).toBeTruthy();
expect(screen.getByText("25.0%")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("2.3 ms")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.getByText("warn")).toBeTruthy();
expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0);
expect(screen.getByText("12.0 MiB")).toBeTruthy();
expect(screen.getByText("2.0 KiB")).toBeTruthy();
const alerts = screen.getAllByRole("alert");
expect(alerts.some((alert) => /disk space low/i.test(alert.textContent ?? ""))).toBe(true);
expect(alerts.some((alert) => /5 queries dropped/i.test(alert.textContent ?? ""))).toBe(true);
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
expect(screen.getByText("90.0%")).toBeTruthy();
expect(screen.getByText("100.0%")).toBeTruthy();
expect(screen.getByText("1/2 available")).toBeTruthy();
});
test("live state is labeled on its own card, not by a section that disowns the picker", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.queryByRole("region", { name: "Right now" })).toBeNull();
expect(screen.queryByText("Right now")).toBeNull();
expect(screen.queryByText("Snapshot state; the period above does not apply.")).toBeNull();
});
test("the period picker rescopes the upstream table", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
await screen.findByText("90.0%");
fireEvent.click(screen.getByRole("button", { name: "1h" }));
await screen.findByText("85.7%");
expect(screen.getByRole("columnheader", { name: "Selected period · 1h" })).toBeTruthy();
expect(screen.queryByText("90.0%")).toBeNull();
});
test("period picker refetches stats and shows the empty chart state", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
fireEvent.click(screen.getByRole("button", { name: "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");
await screen.findByText("No queries in this period.");
expect(screen.getByText("—", { selector: "span" })).toBeTruthy();
});
test("one failing endpoint degrades its own widget on cold navigation", async () => {
failing.add("/api/upstream/health?period=24h");
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
// The page renders; only the upstream widget carries the error.
await screen.findByText("upstream health unavailable");
expect(screen.queryByText("Something went wrong")).toBeNull();
expect(screen.queryByText("Request failed (400)")).toBeNull();
expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
});
@@ -1,173 +0,0 @@
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards";
import TimeseriesChart from "./TimeseriesChart";
import UpstreamHealthTable from "./UpstreamHealthTable";
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
titleRow: {
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,
},
/** Dynamic: the caller sizes the placeholder to the widget it stands in for. */
skeletonHeight: (height: number) => ({ height }),
skeleton: {
borderRadius: "0.25rem",
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
},
},
/** The chart takes two thirds beside the storage card from `lg`, one column below. */
panelGrid: {
display: "grid",
gap: "1rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 1024px)": "2fr 1fr",
},
},
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
});
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>
);
}
function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
}
export default function DashboardPage() {
const [period, setPeriod] = useState<Period>("24h");
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
const health = useQuery(healthQuery());
const upstreamHealth = useQuery({ ...upstreamHealthQuery(period), placeholderData: keepPreviousData });
return (
<section {...stylex.props(styles.page)}>
<div {...stylex.props(styles.titleRow)}>
<h1 {...stylex.props(styles.heading)}>Dashboard</h1>
<PeriodPicker period={period} onChange={setPeriod} />
</div>
{health.data !== undefined && <HealthBanners health={health.data} />}
{stats.isError ? (
<InlineError error={stats.error} onRetry={() => void stats.refetch()} />
) : stats.data === undefined ? (
<Skeleton height={76} />
) : (
<StatCards stats={stats.data} />
)}
{/* One notice for the period: the chart is judged against the same
aligned lower bound as the totals, so it would say the same thing. */}
{stats.data !== undefined && <CoverageNotice coverage={stats.data.coverage} />}
<div {...stylex.props(styles.panelGrid)}>
<section {...stylex.props(styles.panel)}>
<h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
{timeseries.isError ? (
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
) : timeseries.data === undefined ? (
<Skeleton height={240} />
) : (
<TimeseriesChart data={timeseries.data} />
)}
</section>
{health.data === undefined ? <Skeleton height={160} /> : <DiskCard disk={health.data.disk} />}
</div>
{upstreamHealth.isError ? (
<InlineError error={upstreamHealth.error} onRetry={() => void upstreamHealth.refetch()} />
) : upstreamHealth.data === undefined ? (
<Skeleton height={120} />
) : (
<UpstreamHealthTable health={upstreamHealth.data} />
)}
</section>
);
}
-99
View File
@@ -1,99 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
heading: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
badge: {
borderRadius: "0.25rem",
paddingInline: "0.5rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/**
* The badge fills are their own three-step scale, not the `danger`/`warn`
* banner tokens: they read as a tinted chip against a raised card, where a
* banner fill would be too heavy.
*/
ok: {
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
warn: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
critical: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
row: {
display: "flex",
justifyContent: "space-between",
},
term: {
color: colors.textMuted,
},
});
function stateStyle(state: Health["disk"]["state"]) {
if (state === "critical") return styles.critical;
return state === "warn" ? styles.warn : styles.ok;
}
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
return (
<section {...stylex.props(styles.card)}>
{/* Live state, unlike the ranged widgets around it; the title says so
rather than a section rule the picker would have to disown. */}
<h2 {...stylex.props(styles.heading)}>
Storage now
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
</h2>
<dl {...stylex.props(styles.list)}>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Free</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Database</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Logs</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
</div>
</dl>
</section>
);
}
@@ -1,68 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
stack: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
banner: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
warn: {
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
},
critical: {
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
color: colors.dangerText,
},
});
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
return (
<p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
{children}
</p>
);
}
export default function HealthBanners({ health }: { health: Health }) {
const banners: React.ReactNode[] = [];
if (health.disk.state !== "ok") {
banners.push(
<Banner key="disk" tone={health.disk.state === "critical" ? "critical" : "warn"}>
{health.disk.state === "critical"
? `Disk critically low: ${formatBytes(health.disk.free_bytes)} free. Blocklist updates and log flushes are stopped.`
: `Disk space low: ${formatBytes(health.disk.free_bytes)} free.`}
</Banner>,
);
}
if (health.writer_failed) {
banners.push(
<Banner key="writer" tone="critical">
Query log writer failed; new queries are not being persisted.
</Banner>,
);
}
if (health.queries_dropped > 0) {
banners.push(
<Banner key="dropped" tone="warn">
{health.queries_dropped.toLocaleString()} queries dropped from the log buffer.
</Banner>,
);
}
if (banners.length === 0) return null;
return <div {...stylex.props(styles.stack)}>{banners}</div>;
}
@@ -1,85 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
},
},
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
value: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
detail: {
marginLeft: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
function percentOf(part: number, total: number): string | null {
if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`;
}
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
return (
<div {...stylex.props(styles.card)}>
<dt {...stylex.props(styles.label)}>{label}</dt>
<dd>
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd>
</div>
);
}
export default function StatCards({ stats }: { stats: StatsTotals }) {
return (
<dl {...stylex.props(styles.grid)}>
<Card label="Queries" value={numberFormat.format(stats.queries)} />
<Card
label="Blocked"
value={numberFormat.format(stats.blocked)}
detail={percentOf(stats.blocked, stats.queries)}
/>
<Card
label="Cached"
value={numberFormat.format(stats.cached)}
detail={percentOf(stats.cached, stats.queries)}
/>
<Card label="Clients" value={numberFormat.format(stats.clients)} />
<Card
label="Avg response"
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
/>
</dl>
);
}
@@ -1,165 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
import UpstreamHealthTable from "./UpstreamHealthTable";
const NOW_S = 1_700_000_000;
const ZERO: UpstreamPeriodStats = {
attempts: 0,
successes: 0,
failures: 0,
success_rate: null,
last_failure_at: null,
last_failure_error: null,
};
function period(overrides: Partial<UpstreamPeriodStats> = {}): UpstreamPeriodStats {
return {
attempts: 100,
successes: 90,
failures: 10,
success_rate: 0.9,
last_failure_at: NOW_S - 12_600,
last_failure_error: "Timeout",
...overrides,
};
}
function entry(overrides: Partial<UpstreamHealthEntry> = {}): UpstreamHealthEntry {
return {
url: "https://dns.example/dns-query",
enabled: true,
available: true,
period: period(),
...overrides,
};
}
function renderTable(upstreams: UpstreamHealthEntry[], overrides: Partial<UpstreamHealth> = {}) {
const health: UpstreamHealth = {
period: "24h",
since: NOW_S - 86_400,
until: NOW_S,
available: upstreams.filter((upstream) => upstream.available).length,
total: upstreams.length,
complete: true,
upstreams,
...overrides,
};
render(<UpstreamHealthTable health={health} />);
}
function rowOf(url: string): HTMLElement {
const cell = screen.getByText(url);
const row = cell.closest("tr");
if (row === null) throw new Error(`no row for ${url}`);
return row;
}
test("the ranged columns sit under a header naming the selected period", () => {
renderTable([entry()]);
expect(screen.getByRole("columnheader", { name: "Selected period · 24h" })).toBeTruthy();
for (const name of ["Upstream", "Status now", "Attempts", "Failures", "Success rate"]) {
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
}
// The unranged yes/no pair the ranged table replaced.
expect(screen.queryByRole("columnheader", { name: "Enabled" })).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Available" })).toBeNull();
});
test("failure detail is the Diagnostics page's job; the card never shows it", () => {
renderTable([entry()]);
expect(screen.queryByRole("columnheader", { name: "Last failure" })).toBeNull();
expect(screen.queryByText(/Timeout/)).toBeNull();
expect(screen.queryByText(/ago$/)).toBeNull();
});
test("status now is one word from live state, not from the window", () => {
renderTable([
entry({ url: "https://a.example/dns-query" }),
entry({ url: "https://b.example/dns-query", available: false }),
entry({ url: "https://c.example/dns-query", enabled: false, available: false }),
]);
expect(within(rowOf("https://a.example/dns-query")).getByText("Available")).toBeTruthy();
expect(within(rowOf("https://b.example/dns-query")).getByText("Backing off")).toBeTruthy();
expect(within(rowOf("https://c.example/dns-query")).getByText("Disabled")).toBeTruthy();
});
test("a window with no attempts renders an em-dash and never a perfect rate", () => {
renderTable([entry({ period: ZERO })]);
const cells = within(rowOf("https://dns.example/dns-query")).getAllByRole("cell");
expect(cells.map((cell) => cell.textContent)).toEqual([
"https://dns.example/dns-query",
"Available",
"0",
"0",
"—",
]);
expect(screen.queryByText("100.0%")).toBeNull();
expect(screen.queryByText("0.0%")).toBeNull();
});
test("the card says so when every upstream was idle in the window", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry({ period: ZERO })]);
expect(screen.getByText("No upstream attempts in this period.")).toBeTruthy();
});
test("one upstream with attempts keeps the idle message away", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry()]);
expect(screen.queryByText("No upstream attempts in this period.")).toBeNull();
});
test("an incomplete window carries a note; a complete one claims nothing", () => {
renderTable([entry()], { complete: false });
expect(screen.getByText(/history incomplete/i)).toBeTruthy();
});
test("a complete window shows no completeness text at all", () => {
renderTable([entry()], { complete: true });
expect(screen.queryByText(/history incomplete/i)).toBeNull();
expect(screen.queryByText(/complete/i)).toBeNull();
});
test("an empty pool says so instead of drawing a table", () => {
renderTable([]);
expect(screen.getByText("No upstreams configured.")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});
test("a rate a hair under perfect never rounds up to 100.0% while failures stand", () => {
// The real row that produced this: 12,698 attempts, 2 failures, 99.984%.
renderTable([
entry({
period: period({ attempts: 12_698, successes: 12_696, failures: 2, success_rate: 12_696 / 12_698 }),
}),
]);
expect(screen.queryByText("100.0%")).toBeNull();
expect(screen.getByText("99.9%")).toBeTruthy();
});
test("a rate a hair above nothing never rounds down to 0.0% while successes stand", () => {
renderTable([
entry({
period: period({ attempts: 12_698, successes: 2, failures: 12_696, success_rate: 2 / 12_698 }),
}),
]);
expect(screen.queryByText("0.0%")).toBeNull();
expect(screen.getByText("0.1%")).toBeTruthy();
});
test("a window with no failures at all still reads 100.0%", () => {
renderTable([entry({ period: period({ attempts: 500, successes: 500, failures: 0, success_rate: 1 }) })]);
expect(screen.getByText("100.0%")).toBeTruthy();
});
@@ -1,226 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
},
heading: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
paddingInline: "1rem",
paddingTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
count: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
empty: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
note: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tableWrap: {
overflowX: "auto",
},
table: {
marginTop: "0.5rem",
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/**
* The two live columns are left outside the span: everything under it answers
* for the selected window, and nothing else on this card does.
*/
groupRow: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
groupHead: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "1rem",
paddingBottom: "0.25rem",
textAlign: "center",
fontWeight: 500,
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
textAlign: "left",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
th: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontWeight: 500,
},
thRight: {
textAlign: "right",
},
/** No hairline under the last row: the card border already closes the table. */
row: {
borderBottomWidth: { default: 1, ":last-child": 0 },
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
},
cellRight: {
textAlign: "right",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
bad: {
color: colors.danger,
},
});
/** Live pool state in one word. Configuration first: a disabled upstream is not backing off. */
function statusNow(upstream: UpstreamHealthEntry): "Available" | "Backing off" | "Disabled" {
if (!upstream.enabled) return "Disabled";
return upstream.available ? "Available" : "Backing off";
}
/**
* `success_rate` is null exactly when the window holds no attempt, and that must
* not read as perfect reliability hence the em-dash rather than `100.0%`.
*
* One decimal place cannot hold 12,696 of 12,698: it rounds to `100.0%`, and the
* row then claims perfection beside a failure count of 2. Neither endpoint may
* be reached by rounding only by actually having no failure, or no success.
*/
function successRate(period: UpstreamPeriodStats): string {
if (period.success_rate === null) return "—";
const rounded = period.success_rate * 100;
if (rounded > 99.9 && period.failures > 0) return "99.9%";
if (rounded < 0.1 && period.successes > 0) return "0.1%";
return `${rounded.toFixed(1)}%`;
}
/**
* The dashboard answers availability only. Failure detail what failed, when,
* and how often is the Diagnostics page's job, so `last_failure_at` and
* `last_failure_error` are read there rather than repeated in this row.
*/
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
const idle = health.upstreams.length > 0 && health.upstreams.every(({ period }) => period.attempts === 0);
return (
<section {...stylex.props(styles.card)}>
<h2 {...stylex.props(styles.heading)}>
Upstreams
<span {...stylex.props(styles.count, shared.tabularNums)}>
{health.available}/{health.total} available
</span>
</h2>
{health.upstreams.length === 0 ? (
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
) : (
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.groupRow)}>
<td colSpan={2} />
<th scope="colgroup" colSpan={3} {...stylex.props(styles.groupHead)}>
Selected period · {health.period}
</th>
</tr>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.th)}>
Upstream
</th>
<th scope="col" {...stylex.props(styles.th)}>
Status now
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Attempts
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Failures
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Success rate
</th>
</tr>
</thead>
<tbody>
{health.upstreams.map((upstream) => {
const status = statusNow(upstream);
return (
<tr key={upstream.url} {...stylex.props(styles.row)}>
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>
{upstream.url}
</td>
<td {...stylex.props(styles.cell)}>
<span
{...stylex.props(
status === "Backing off" && styles.bad,
status === "Disabled" && styles.muted,
)}
>
{status}
</span>
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.attempts)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.failures)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{successRate(upstream.period)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{idle && <p {...stylex.props(styles.note)}>No upstream attempts in this period.</p>}
{!health.complete && (
<p {...stylex.props(styles.note)}>
History incomplete: outcomes were dropped in this window, so these counts are a lower bound.
</p>
)}
</section>
);
}
@@ -5,6 +5,7 @@ import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { DIAGNOSTIC_CODES, type DiagnosticEvent } from "@/lib/types";
import { health } from "@/lib/healthFixture";
import { EVENT_COPY } from "./eventCopy";
const NOW_S = Math.floor(Date.now() / 1000);
@@ -35,6 +36,8 @@ beforeEach(() => {
requested = [];
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
// The shell reads health for the Diagnostics nav badge on every route.
"/api/health": health(),
};
vi.stubGlobal(
"fetch",
@@ -4,6 +4,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { DiagnosticEvent, DiagnosticsPage } from "@/lib/types";
// Ages are rendered against the wall clock, so the fixtures are anchored to it
@@ -68,6 +69,9 @@ beforeEach(() => {
requested = [];
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
// The health strip at the top of the page; quiet on a healthy box, which is
// what every test below wants it to be.
"/api/health": health(),
"/api/diagnostics?state=active": ACTIVE,
"/api/diagnostics?state=resolved": RESOLVED,
};
@@ -256,7 +260,7 @@ test("only the resolved history offers a purge", async () => {
// An episode still failing is the state of the box, not history: no purge
// affordance anywhere on its card.
const active = screen.getByText("Blocklist source failed to update").closest("li")!;
const active = (await screen.findByText("Blocklist source failed to update")).closest("li")!;
expect(within(active).queryByRole("button", { name: "Purge" })).toBeNull();
const row = screen.getByText("Disk space low").closest("tr")!;
@@ -17,6 +17,7 @@ import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import HealthStrip from "./HealthStrip";
import SeverityBadge from "./SeverityBadge";
import { diagnosticsFilterOf } from "./filter";
import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy";
@@ -401,6 +402,8 @@ export default function DiagnosticsPage() {
repeats, and closes when the subject recovers.
</p>
<HealthStrip />
<RangeNotice since={search.since} until={search.until} />
<div {...stylex.props(styles.filterGrid)}>
@@ -0,0 +1,195 @@
/**
* The health strip on the Diagnostics page, through the real router.
*
* Its load contract is migrated whole from the deleted Overview status section:
* a visible loading state before the first reading, an error row with Retry when
* the first read fails, and a refetch failure that marks the conditions on
* screen as the last reading rather than the current state. What is new is that
* a condition explained on this page narrows this page instead of navigating.
*/
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 { health } from "@/lib/healthFixture";
import type { Health } from "@/lib/types";
let healthBody: Health;
let healthFails: boolean;
let requested: string[];
/** Held open to keep a health request in flight while a test looks at the strip. */
let pendingHealth: Promise<void> | null;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
healthBody = health();
healthFails = false;
requested = [];
pendingHealth = null;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
requested.push(url);
if (url === "/api/health") {
if (pendingHealth !== null) await pendingHealth;
return healthFails ? json({ error: "health unavailable" }, 400) : 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();
vi.useRealTimers();
});
function renderDiagnostics(path = "/diagnostics") {
const queryClient = createQueryClient();
const defaults = queryClient.getDefaultOptions();
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { router, queryClient };
}
function strip(): HTMLElement {
return screen.getByRole("list", { name: "Current status" });
}
function fact(label: string): HTMLElement {
// First match, not only match: a condition's label and the link it offers can
// be the same word — Upstreams links to Upstreams — and the label comes first.
const cell = within(strip()).getAllByText(label)[0];
const item = cell.closest("li");
if (item === null) throw new Error(`no health fact for ${label}`);
return item;
}
test("the five conditions are stated in words, healthy ones without a way out", async () => {
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
for (const [label, value] of [
["Protection", "Active"],
["Upstreams", "Available"],
["Query history", "Recording"],
["Diagnostics", "Recording"],
["Storage", "OK"],
] as const) {
expect(within(fact(label)).getByText(value)).toBeTruthy();
}
expect(within(strip()).queryByRole("link")).toBeNull();
});
test("protection unavailable sends the reader to Blocklists, upstreams to Upstreams", async () => {
healthBody = health({
protection: { state: "unavailable", until: null },
upstreams: { state: "unavailable", available: 0, total: 2 },
});
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Protection")).getByRole("link", { name: "Blocklists" }).getAttribute("href")).toBe(
"/blocklists",
);
expect(within(fact("Upstreams")).getByRole("link", { name: "Upstreams" }).getAttribute("href")).toBe("/upstreams");
});
test("a losing query log narrows this page to the disk, a failed writer to the query log", async () => {
healthBody = health({ query_history: { state: "losing", dropped_total: 4, last_drop_s: null } });
const { router } = renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Query history")).getByText("4 queries dropped")).toBeTruthy();
fireEvent.click(within(fact("Query history")).getByRole("link", { name: "Disk diagnostics" }));
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
await waitFor(() => expect(requested.some((url) => url.includes("component=disk"))).toBe(true));
});
test("a filter link drops a time window that would hide the episodes it points at", async () => {
healthBody = health({ disk: { state: "critical", free_bytes: 0 } });
const { router } = renderDiagnostics("/diagnostics?since=1000&until=2000&severity=error&state=resolved");
await waitFor(() => expect(strip()).toBeTruthy());
fireEvent.click(within(fact("Storage")).getByRole("link", { name: "Disk diagnostics" }));
// Everything that could hide the episode goes with the bounds: `state=resolved`
// would exclude the active disk episode this link exists to show, and an
// `error` severity would exclude it whenever it is a warning.
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
});
test("an unavailable diagnostics store explains itself and offers no link into itself", async () => {
healthBody = health({ diagnostics: { state: "unavailable", active_warnings: 0, active_errors: 0 } });
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
const row = fact("Diagnostics");
expect(within(row).getByText(/not being recorded/)).toBeTruthy();
expect(within(row).queryByRole("link")).toBeNull();
});
test("the strip says it is loading before the first reading, never empty conditions", async () => {
// Through the route, which is the path that matters: the loader starts the
// health request without waiting for it, so the page paints while the reading
// is still in flight and the strip has to say so.
let release = () => {};
pendingHealth = new Promise<void>((resolve) => {
release = resolve;
});
renderDiagnostics();
expect(await screen.findByText("Loading status…")).toBeTruthy();
expect(screen.queryByText("Protection")).toBeNull();
release();
await waitFor(() => expect(strip()).toBeTruthy());
expect(screen.queryByText("Loading status…")).toBeNull();
});
test("a failed first health read is an error row with Retry, not a healthy strip", async () => {
healthFails = true;
renderDiagnostics();
await screen.findByText("health unavailable");
expect(screen.queryByRole("list", { name: "Current status" })).toBeNull();
});
test("a reading that has gone stale says so rather than passing for current", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderDiagnostics();
await vi.waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
// The next poll fails. The conditions on screen are the last that arrived and
// must not keep passing for the current state.
healthFails = true;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.getByText(/last reading that arrived/)).toBeTruthy());
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
// Recovery clears the caption rather than leaving the page permanently unsure.
healthFails = false;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.queryByText(/last reading that arrived/)).toBeNull());
});
@@ -0,0 +1,178 @@
/**
* The five health conditions, compactly, at the top of the page that explains
* failures. Healthy conditions stay quiet; a degraded one is highlighted and
* offers the way out.
*
* A degraded condition whose explanation is on this page narrows this page
* rather than navigating away: the filter link sets `component` and drops every
* other filter. A time window, a severity or a `state=resolved` left from an
* earlier investigation would each hide the very episode the reader was sent to
* read, and a link that lands on "no events" states something false.
*
* The load contract is the one the deleted Overview status section carried: a
* visible loading state before the first reading, and a refetch failure that
* says so the conditions on screen become the last reading that arrived, never
* a claim about the current state, until a poll succeeds again.
*/
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { healthQuery } from "@/lib/queries";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { healthFacts, type FactLink, type FactTone, type HealthFact } from "./healthFacts";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
list: {
marginTop: "0.75rem",
display: "grid",
gap: "0.5rem",
// Five is prime, so every count between one and five leaves a short last
// row; three columns made it 3 then 2, which reads as a layout that ran out
// of room rather than one that chose. So the strip goes from one column
// straight to two and then to a single row of five, and is never ragged.
gridTemplateColumns: {
default: "minmax(0, 1fr)",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1100px)": "repeat(5, minmax(0, 1fr))",
},
listStyleType: "none",
padding: 0,
},
fact: {
display: "flex",
flexWrap: "wrap",
alignItems: "baseline",
gap: "0.375rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "0.625rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** Quiet: a healthy condition is the normal state and gets no emphasis. */
quiet: {
borderColor: colors.border,
backgroundColor: "transparent",
},
highlighted: {
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
},
label: {
color: colors.textSecondary,
},
value: {
fontWeight: 500,
},
detail: {
flexBasis: "100%",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
link: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
ok: {
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
notice: {
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
warn: {
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
danger: {
color: colors.dangerText,
},
message: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
/** Text and icon carry the state; the colour only agrees with them. */
const ICONS: Record<FactTone, string> = { ok: "●", notice: "‖", warn: "!", danger: "✕" };
function FactLinkAnchor({ link }: { link: FactLink }) {
if (link.kind === "filter") {
return (
<Link
to="/diagnostics"
// Sets the component and clears every filter that could hide what it
// points at: a time window from an older investigation, and a severity
// or state — `resolved` above all — that would exclude the very episode
// explaining the condition this link came from.
search={() => ({ component: link.component })}
{...stylex.props(styles.link, shared.focusRing)}
>
{link.label}
</Link>
);
}
return (
<Link to={link.to} {...stylex.props(styles.link, shared.focusRing)}>
{link.label}
</Link>
);
}
function Fact({ fact }: { fact: HealthFact }) {
return (
<li {...stylex.props(styles.fact, fact.tone === "ok" ? styles.quiet : styles.highlighted)}>
<span aria-hidden="true" {...stylex.props(TONES[fact.tone])}>
{ICONS[fact.tone]}
</span>
<span {...stylex.props(styles.label)}>{fact.label}</span>
<span {...stylex.props(styles.value, TONES[fact.tone])}>{fact.value}</span>
{fact.link !== undefined && <FactLinkAnchor link={fact.link} />}
{fact.detail !== undefined && <span {...stylex.props(styles.detail)}>{fact.detail}</span>}
</li>
);
}
export default function HealthStrip() {
const health = useQuery(healthQuery());
if (health.data === undefined) {
return health.isError ? (
<InlineError error={health.error} onRetry={() => void health.refetch()} />
) : (
<p role="status" {...stylex.props(styles.message, shared.pulse)}>
Loading status
</p>
);
}
return (
<>
<ul aria-label="Current status" {...stylex.props(styles.list)}>
{healthFacts(health.data).map((fact) => (
<Fact key={fact.key} fact={fact} />
))}
</ul>
{health.isError && (
<>
<p role="status" {...stylex.props(styles.message)}>
This is the last reading that arrived. The current state is unknown.
</p>
<InlineError error={health.error} onRetry={() => void health.refetch()} />
</>
)}
</>
);
}
@@ -48,3 +48,20 @@ test("component labels read as prose without inventing a name", () => {
expect(componentLabel("query_log")).toBe("Query log");
expect(componentLabel("disk")).toBe("Disk");
});
test("the legacy upstream_history code keeps its copy, so a retained episode still reads as prose", () => {
// Nothing emits it any more, but stored rows outlive the subsystem and the
// list endpoint passes their codes through verbatim.
expect(DIAGNOSTIC_CODES).toContain("upstream_history.write");
const copy = EVENT_COPY["upstream_history.write"];
expect(copy.title).toBe("Upstream history write failed");
expect(copy.impact).toMatch(/no longer runs/);
});
test("the recreated-log copy covers a planned schema change as well as an unreadable file", () => {
const copy = EVENT_COPY["query_log.recreated"];
// The planned cause leads, because it is the one an upgrade produces; the
// unreadable file is the other cause and must not be dropped from the copy.
expect(copy.impact).toMatch(/schema/);
expect(copy.impact).toMatch(/could not be read/);
});
+9 -3
View File
@@ -111,14 +111,20 @@ export const EVENT_COPY: Record<DiagnosticCode, EventCopy> = {
},
"query_log.recreated": {
title: "Query log recreated",
impact: "The old log database was unreadable and was moved aside; the history it held is not in the new one.",
impact: "The old log database was moved aside — a release changed its schema, or the file could not be read — and the history it held is not in the new one.",
remediation: "Keep or delete the aside file named below. Nothing else is required — logging is running.",
link: SETTINGS,
},
/**
* Legacy. Nothing emits this code any more: milestone 30 deleted the
* upstream-minute history subsystem. Stored rows outlive it, and the list
* endpoint passes their codes through verbatim, so the copy stays a
* retained episode must still read as prose rather than as a dotted string.
*/
"upstream_history.write": {
title: "Upstream history write failed",
impact: "Resolution is unaffected; the per-upstream success and failure aggregates lose the affected window.",
remediation: "Check free disk space and the configuration database's permissions.",
impact: "A recorded failure of a subsystem this version no longer runs. Resolution was unaffected; the per-upstream aggregates it fed are gone.",
remediation: "Nothing to do. Purge the entry once you have read it.",
link: UPSTREAMS,
},
"upstream.exchange": {
@@ -0,0 +1,97 @@
/**
* The health-fact matrix, migrated whole from the Overview status rows this
* replaces. Same states, same words, same link matrix with the Diagnostics
* links now narrowing the page the strip sits on rather than navigating to it.
*/
import { health } from "@/lib/healthFixture";
import { healthFacts, type HealthFact } from "./healthFacts";
const LOCALE = "en-GB";
const TZ = "UTC";
function factsBy(overrides: Parameters<typeof health>[0] = {}): Record<string, HealthFact> {
return Object.fromEntries(healthFacts(health(overrides), LOCALE, TZ).map((fact) => [fact.key, fact]));
}
test("a healthy box is five quiet facts, none of them linking anywhere", () => {
const facts = healthFacts(health(), LOCALE, TZ);
expect(facts.map((fact) => fact.key)).toEqual(["protection", "upstreams", "query_history", "diagnostics", "disk"]);
expect(facts.every((fact) => fact.tone === "ok")).toBe(true);
expect(facts.every((fact) => fact.link === undefined)).toBe(true);
});
test("protection: active, paused indefinitely, paused until a time, unavailable", () => {
expect(factsBy()["protection"].value).toBe("Active");
expect(factsBy({ protection: { state: "paused", until: null } })["protection"].value).toBe("Paused");
const timed = factsBy({ protection: { state: "paused", until: Date.UTC(2026, 0, 1, 14, 5) / 1000 } })["protection"];
expect(timed.value).toBe("Paused until 14:05");
const gone = factsBy({ protection: { state: "unavailable", until: null } })["protection"];
expect(gone.value).toBe("Unavailable");
expect(gone.link).toEqual({ kind: "route", to: "/blocklists", label: "Blocklists" });
});
test("a pause never reads as a fault, and never carries a way out", () => {
const paused = factsBy({ protection: { state: "paused", until: null } })["protection"];
expect(paused.tone).toBe("notice");
expect(paused.link).toBeUndefined();
});
test("upstreams count the enabled pool, and only an empty one links out", () => {
const ok = factsBy({ upstreams: { state: "ok", available: 1, total: 3 } })["upstreams"];
expect(ok.value).toBe("Available");
expect(ok.detail).toBe("1 of 3 enabled");
expect(ok.link).toBeUndefined();
const none = factsBy({ upstreams: { state: "unavailable", available: 0, total: 3 } })["upstreams"];
expect(none.value).toBe("None reachable");
expect(none.link).toEqual({ kind: "route", to: "/upstreams", label: "Upstreams" });
});
test("query history: losing blames the disk gate, a failed writer blames the query log", () => {
const losing = factsBy({ query_history: { state: "losing", dropped_total: 0, last_drop_s: null } })[
"query_history"
];
expect(losing.value).toBe("Losing rows");
expect(losing.link).toEqual({ kind: "filter", component: "disk", label: "Disk diagnostics" });
const failed = factsBy({ query_history: { state: "failed", dropped_total: 0, last_drop_s: null } })[
"query_history"
];
expect(failed.value).toBe("Writer failed");
expect(failed.link).toEqual({ kind: "filter", component: "query_log", label: "Query log diagnostics" });
});
test("drops are reported while recording, with or without a stamp on the last one", () => {
const stamped = factsBy({
query_history: { state: "recording", dropped_total: 5, last_drop_s: Date.UTC(2026, 0, 1, 9, 30) / 1000 },
})["query_history"];
expect(stamped.tone).toBe("ok");
expect(stamped.detail).toBe("5 queries dropped, last at 09:30");
const unstamped = factsBy({ query_history: { state: "recording", dropped_total: 1, last_drop_s: null } })[
"query_history"
];
expect(unstamped.detail).toBe("1 query dropped");
expect(factsBy()["query_history"].detail).toBeUndefined();
});
test("an unavailable diagnostics store explains itself and links nowhere", () => {
const fact = factsBy({ diagnostics: { state: "unavailable", active_warnings: 0, active_errors: 0 } })[
"diagnostics"
];
expect(fact.value).toBe("Unavailable");
expect(fact.detail).toContain("not being recorded");
// The page a link would filter is the thing that is broken.
expect(fact.link).toBeUndefined();
});
test("storage names the three disk states and always shows what is free", () => {
expect(factsBy()["disk"].value).toBe("OK");
expect(factsBy()["disk"].link).toBeUndefined();
const low = factsBy({ disk: { state: "low", free_bytes: 1024 } })["disk"];
expect(low.value).toBe("Low");
expect(low.tone).toBe("warn");
expect(low.link).toEqual({ kind: "filter", component: "disk", label: "Disk diagnostics" });
const critical = factsBy({ disk: { state: "critical", free_bytes: 0 } })["disk"];
expect(critical.value).toBe("Critical");
expect(critical.tone).toBe("danger");
expect(critical.detail).toBe("0 B free");
});
@@ -0,0 +1,133 @@
/**
* The five conditions `GET /api/health` reports, as facts.
*
* A pure projection of `Health` so the whole matrix every state of every
* condition, and every way out a degraded one offers is testable without a
* router or a fetch. `HealthStrip` only paints what this returns.
*
* A healthy fact is quiet: no badge, no panel, no green reassurance, so the one
* condition that is not healthy is the thing the eye lands on.
*/
import { formatBytes, formatClock } from "@/lib/format";
import type { Health } from "@/lib/types";
export type FactTone = "ok" | "notice" | "warn" | "danger";
/**
* Where a degraded fact sends the reader. A `filter` link stays on this page and
* narrows it to the component that failed; a `route` link leaves for the surface
* that can fix the condition.
*/
export type FactLink =
| { kind: "route"; to: "/blocklists" | "/upstreams"; label: string }
| { kind: "filter"; component: string; label: string };
export interface HealthFact {
key: "protection" | "upstreams" | "query_history" | "diagnostics" | "disk";
label: string;
tone: FactTone;
/** The state, in the reader's words. Never colour alone. */
value: string;
detail?: string;
link?: FactLink;
}
const numberFormat = new Intl.NumberFormat();
/** Kept out of the state rule: rows are lost whether or not the box is losing them now. */
function dropText(dropped: number, lastDrop: number | null, locale?: string, timeZone?: string): string | undefined {
if (dropped <= 0) return undefined;
const count = `${numberFormat.format(dropped)} ${dropped === 1 ? "query" : "queries"} dropped`;
return lastDrop === null ? count : `${count}, last at ${formatClock(lastDrop, locale, timeZone)}`;
}
function protectionFact(protection: Health["protection"], locale?: string, timeZone?: string): HealthFact {
if (protection.state === "unavailable") {
return {
key: "protection",
label: "Protection",
tone: "danger",
value: "Unavailable",
detail: "No filter snapshot is published, so queries are not being filtered.",
link: { kind: "route", to: "/blocklists", label: "Blocklists" },
};
}
if (protection.state === "paused") {
return {
key: "protection",
label: "Protection",
tone: "notice",
value:
protection.until === null
? "Paused"
: `Paused until ${formatClock(protection.until, locale, timeZone)}`,
};
}
return { key: "protection", label: "Protection", tone: "ok", value: "Active" };
}
function queryHistoryFact(history: Health["query_history"], locale?: string, timeZone?: string): HealthFact {
const detail = dropText(history.dropped_total, history.last_drop_s, locale, timeZone);
if (history.state === "failed") {
return {
key: "query_history",
label: "Query history",
tone: "danger",
value: "Writer failed",
detail,
link: { kind: "filter", component: "query_log", label: "Query log diagnostics" },
};
}
if (history.state === "losing") {
// The disk gate is what is holding the writes back, and it may be the only
// thing that has reported: query_log itself need not have an episode open.
return {
key: "query_history",
label: "Query history",
tone: "danger",
value: "Losing rows",
detail,
link: { kind: "filter", component: "disk", label: "Disk diagnostics" },
};
}
return { key: "query_history", label: "Query history", tone: "ok", value: "Recording", detail };
}
export function healthFacts(health: Health, locale?: string, timeZone?: string): HealthFact[] {
const { upstreams, diagnostics, disk } = health;
return [
protectionFact(health.protection, locale, timeZone),
{
key: "upstreams",
label: "Upstreams",
tone: upstreams.state === "unavailable" ? "danger" : "ok",
value: upstreams.state === "unavailable" ? "None reachable" : "Available",
detail: `${upstreams.available} of ${upstreams.total} enabled`,
...(upstreams.state === "unavailable"
? { link: { kind: "route", to: "/upstreams", label: "Upstreams" } as FactLink }
: {}),
},
queryHistoryFact(health.query_history, locale, timeZone),
diagnostics.state === "unavailable"
? {
key: "diagnostics",
label: "Diagnostics",
tone: "danger",
value: "Unavailable",
// No link: the page it would filter is the thing that is broken.
detail: "Diagnostics are not being recorded. Check free disk space and the configuration database's permissions.",
}
: { key: "diagnostics", label: "Diagnostics", tone: "ok", value: "Recording" },
{
key: "disk",
label: "Storage",
tone: disk.state === "critical" ? "danger" : disk.state === "low" ? "warn" : "ok",
value: disk.state === "critical" ? "Critical" : disk.state === "low" ? "Low" : "OK",
detail: `${formatBytes(disk.free_bytes)} free`,
...(disk.state === "ok"
? {}
: { link: { kind: "filter", component: "disk", label: "Disk diagnostics" } as FactLink }),
},
];
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Client activity over the same window as the query-volume chart: one stacked
* series per named client, plus everything outside the top eight as "Other".
*
* The x-axis is the timeseries endpoint's own bucket alignment, so the two
* charts stack directly above one another and a spike in one is at the same
* horizontal position in the other. Colour keys on the client string, so a
* client that changes rank between polls keeps its colour.
*/
import { useEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { StatsClients } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { layoutStacked } from "./chartLayout";
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
const CHART_HEIGHT = 240;
const FALLBACK_WIDTH = 640;
const styles = stylex.create({
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: CHART_HEIGHT,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
root: {
position: "relative",
},
gridLine: {
stroke: colors.border,
},
axisLine: {
stroke: colors.borderStrong,
},
axisLabel: {
fill: colors.textMuted,
fontSize: "10px",
},
/** The hairline separating touching segments is the page ground, not a colour. */
segment: {
stroke: colors.surface,
},
legend: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
listStyleType: "none",
padding: 0,
margin: 0,
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
legendItem: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
swatch: {
display: "inline-block",
width: "0.625rem",
height: "0.625rem",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the colour the bars are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
});
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (el === null) return;
setWidth(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, width];
}
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
function formatTick(ts: number, bucketSeconds: number): string {
const date = new Date(ts * 1000);
if (bucketSeconds >= 86_400) {
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
}
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
}
interface Series {
key: string;
label: string;
/** Kept beside the label so a renamed client is still identifiable by address. */
address: string | null;
color: string;
buckets: number[];
}
/**
* "Other" last, so it sits at the top of every column rather than under a
* client, and always present: the response always carries the series, and a
* legend that dropped it on a quiet period would make the reader think the
* chart's clients were all of them.
*/
function seriesOf(data: StatsClients, 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 —
// the same precedence and the same lookup the query tables use. The colour
// keys on the address regardless, so naming a client never repaints it.
label: clientLabel(client.client, names)?.text ?? client.client,
address: client.client,
color: seriesColor(clientKey(client.client)),
buckets: client.buckets,
}));
return [
...named,
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
];
}
export default function ClientChart({ data }: { data: StatsClients }) {
const [containerRef, measuredWidth] = useContainerWidth();
const names = useClientNames();
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
const series = seriesOf(data, names);
const bucketCount = data.other.length;
if (bucketCount === 0) {
return (
<div ref={containerRef} {...stylex.props(styles.empty)}>
No queries in this period.
</div>
);
}
const columns = Array.from({ length: bucketCount }, (_, i) => ({
ts: data.since + i * data.bucket_seconds,
values: series.map((one) => one.buckets[i] ?? 0),
}));
if (columns.every((column) => column.values.every((value) => value === 0))) {
return (
<div ref={containerRef} {...stylex.props(styles.empty)}>
No queries in this period.
</div>
);
}
const layout = layoutStacked(columns, width, CHART_HEIGHT);
const baseline = layout.plot.y + layout.plot.height;
return (
<div ref={containerRef} {...stylex.props(styles.root)}>
<svg
role="img"
aria-label={`Client activity over time, ${bucketCount} buckets, ${series.length} series`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
>
{layout.yTicks.map((tick) => (
<g key={tick.value}>
<line
x1={layout.plot.x}
x2={layout.plot.x + layout.plot.width}
y1={tick.y}
y2={tick.y}
{...stylex.props(styles.gridLine)}
/>
<text
x={layout.plot.x - 6}
y={tick.y}
textAnchor="end"
dominantBaseline="middle"
{...stylex.props(styles.axisLabel, shared.tabularNums)}
>
{compact.format(tick.value)}
</text>
</g>
))}
<line
x1={layout.plot.x}
x2={layout.plot.x + layout.plot.width}
y1={baseline}
y2={baseline}
{...stylex.props(styles.axisLine)}
/>
{layout.xTicks.map((tick) => (
<text
key={tick.ts}
x={tick.x}
y={baseline + 14}
textAnchor="middle"
{...stylex.props(styles.axisLabel)}
>
{formatTick(tick.ts, data.bucket_seconds)}
</text>
))}
{layout.columns.map((column) => (
<g key={column.ts}>
{column.segments.map((rect, index) =>
rect.height <= 0 ? null : (
<rect
key={series[index].key}
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
fill={series[index].color}
strokeWidth={rect.width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/>
),
)}
<rect
x={column.slot.x}
y={column.slot.y}
width={column.slot.width}
height={column.slot.height}
fill="transparent"
>
<title>
{`${formatTime(column.ts)}: ${column.total} ${column.total === 1 ? "query" : "queries"}`}
</title>
</rect>
</g>
))}
</svg>
<ul {...stylex.props(styles.legend)}>
{series.map((one) => (
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
{one.label}
</li>
))}
</ul>
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>Queries per client per time bucket</caption>
<thead>
<tr>
<th scope="col">Time</th>
{series.map((one) => (
<th key={one.key} scope="col">
{one.label}
</th>
))}
</tr>
</thead>
<tbody>
{columns.map((column) => (
<tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th>
{column.values.map((value, index) => (
<td key={series[index].key}>{value}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
/**
* A breakdown as a ring, a legend and a table.
*
* The ring is decoration: it carries `aria-hidden` and `focusable="false"`,
* because a non-focusable SVG is still in the accessibility tree and would
* announce a pile of unlabelled paths. Everything the ring says is said again in
* the legend visibly, with the share and the count and once more in a
* visually hidden table, which is the surface a screen reader reads.
*
* Labels can collide: two rows can both be "Unknown", and one upstream name can
* appear under two route kinds. Identity is therefore the caller's `key`, and an
* entry that needs disambiguating carries `secondary` text saying which it is.
*/
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { layoutDonut, type DonutSlice } from "./donutLayout";
const SIZE = 180;
const THICKNESS = 36;
const numberFormat = new Intl.NumberFormat();
/** The width at which the page puts the two donuts side by side, and the page's
* own grid switches on the same query. StyleX will not take it from an import,
* so it is written out in both modules and must be changed in both. */
const TWO_COLUMN = "@media (min-width: 1280px)";
const styles = stylex.create({
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: SIZE,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/**
* Centred while the panels are stacked, left-anchored once they are side by
* side. Stacked, the panel is as wide as the page and a ring pinned to the
* left edge reads as a mistake; in a column it is one of a pair and lines up
* with everything above it.
*/
body: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" },
gap: "1.25rem",
},
ring: {
flexShrink: 0,
},
/**
* Capped and left-anchored. Without the cap the row justifies across whatever
* the panel is given most of a metre of whitespace on a wide monitor and a
* label stops reading as belonging to the count opposite it.
*/
legend: {
flex: 1,
minWidth: "12rem",
maxWidth: "24rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
listStyleType: "none",
padding: 0,
margin: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
legendItem: {
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
},
swatch: {
flexShrink: 0,
alignSelf: "center",
display: "inline-block",
width: "0.625rem",
height: "0.625rem",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the colour the ring is drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
label: {
flex: 1,
minWidth: 0,
overflowWrap: "anywhere",
},
secondary: {
marginLeft: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
count: {
color: colors.textSecondary,
},
share: {
minWidth: "3rem",
textAlign: "right",
color: colors.textMuted,
},
});
function sharePercent(share: number): string {
return `${(share * 100).toFixed(1)}%`;
}
export default function Donut({
slices,
caption,
unit,
}: {
slices: DonutSlice[];
/** Names the hidden table, so a screen reader knows which breakdown it is in. */
caption: string;
/** The column header for the counted thing, e.g. "Queries". */
unit: string;
}) {
const layout = layoutDonut(slices, SIZE, THICKNESS);
if (layout.total === 0) {
return <div {...stylex.props(styles.empty)}>No queries in this period.</div>;
}
return (
<div {...stylex.props(styles.body)}>
<svg
aria-hidden="true"
focusable="false"
width={SIZE}
height={SIZE}
viewBox={`0 0 ${SIZE} ${SIZE}`}
{...stylex.props(styles.ring)}
>
{layout.arcs.map((arc) => (
// The stroke is what keeps a shared hue from lying. Colour is a pure
// function of identity, so two neighbouring slices can come out the
// same; outlined in the panel's own colour they still read as two
// shapes rather than merging into one. Attributes rather than a
// class, as the client chart's segments are, so the separation is
// visible to a test and not only to a stylesheet.
<path
key={arc.slice.key}
d={arc.d}
fill={arc.slice.color}
fillRule="evenodd"
stroke={colors.surfaceRaised}
strokeWidth={1}
/>
))}
</svg>
<ul {...stylex.props(styles.legend)}>
{layout.arcs.map((arc) => (
<li key={arc.slice.key} {...stylex.props(styles.legendItem)}>
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchColor(arc.slice.color))}
/>
<span {...stylex.props(styles.label)}>
{arc.slice.label}
{arc.slice.secondary !== undefined && (
<span {...stylex.props(styles.secondary)}>{arc.slice.secondary}</span>
)}
</span>
<span {...stylex.props(styles.count, shared.tabularNums)}>
{numberFormat.format(arc.slice.value)}
</span>
<span {...stylex.props(styles.share, shared.tabularNums)}>{sharePercent(arc.share)}</span>
</li>
))}
</ul>
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>{caption}</caption>
<thead>
<tr>
<th scope="col">Entry</th>
<th scope="col">{unit}</th>
<th scope="col">Share</th>
</tr>
</thead>
<tbody>
{layout.arcs.map((arc) => (
<tr key={arc.slice.key}>
<th scope="row">
{arc.slice.secondary === undefined
? arc.slice.label
: `${arc.slice.label} (${arc.slice.secondary})`}
</th>
<td>{arc.slice.value}</td>
<td>{sharePercent(arc.share)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -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);
});
@@ -0,0 +1,248 @@
/**
* Overview: what the resolver did over a period the reader chooses, in the
* layout Pi-hole's dashboard established four totals, two full-width charts,
* two breakdown donuts. Nothing on this page is a current-state readout; the
* five health conditions live on Diagnostics, and protection lives in the
* sidebar beside its control.
*
* 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.
*/
import * as stylex from "@stylexjs/stylex";
import { useNavigate, useSearch } from "@tanstack/react-router";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { qtypeName } from "@/features/queries/qtype";
import type { Period, StatsRoutes, StatsTypes } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
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 "./donutLayout";
import { useOverviewWindow, type Panel } from "./overviewWindow";
import { DEFAULT_PERIOD, PERIODS } from "./period";
import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
/**
* Where the two donuts stop competing for width and sit side by side. `Donut`
* carries the same query for the alignment it switches at that width; StyleX
* requires the string to be a literal in the module that uses it, so the two
* agree by inspection rather than by sharing a constant.
*/
const TWO_COLUMN = "@media (min-width: 1280px)";
const ROUTE_LABELS = {
blocked: "Blocked",
cache: "Cache",
local: "Local",
rejected: "Rejected",
upstream: "Upstream",
forward_zone: "Forward zone",
} 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,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
donutRow: {
display: "grid",
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.
*/
function PanelBody<T>({ panel, children }: { panel: Panel<T>; children: (data: T) => 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>
);
}
return <>{children(panel.data)}</>;
}
function typeSlices(data: StatsTypes): DonutSlice[] {
return data.types.map((row) => ({
key: qtypeKey(row.qtype),
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
value: row.count,
color: seriesColor(qtypeKey(row.qtype)),
}));
}
/**
* Route slices. A row that names a resolver or a zone is labelled by that name
* with the route kind as secondary text, because one name can legitimately
* 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) => {
const named = row.route === "upstream" || row.route === "forward_zone";
return {
key: routeKey(row.route, row.source),
label: named ? (row.source ?? "Unknown") : ROUTE_LABELS[row.route],
...(named ? { secondary: ROUTE_LABELS[row.route] } : {}),
value: row.count,
color: seriesColor(routeKey(row.route, row.source)),
};
});
}
export default function OverviewPage() {
const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
const navigate = useNavigate({ from: "/overview" });
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>
<PanelBody panel={overview.totals}>{(totals) => <StatTiles stats={totals} />}</PanelBody>
{/* 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>
<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>
<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>
);
}
+156
View File
@@ -0,0 +1,156 @@
/**
* The window's four headline numbers, each with the way into the rows behind it.
*
* Neutral chrome throughout: no coloured accents, no per-tile tone. Emphasis is
* 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
* bounds computed here a client-computed window would send the reader to a
* slightly different span than the one they were just reading.
*/
import * as stylex from "@stylexjs/stylex";
import { Link } from "@tanstack/react-router";
import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
/** Two columns on a phone, the whole set of four in one row from `md`. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(4, minmax(0, 1fr))",
},
},
tile: {
display: "flex",
flexDirection: "column",
gap: "0.125rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
valueRow: {
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
flexWrap: "wrap",
},
value: {
fontSize: "1.875rem",
lineHeight: "2.25rem",
fontWeight: 600,
},
detail: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textSecondary,
},
footer: {
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
},
link: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
});
function percentOf(part: number, total: number): string | null {
if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`;
}
function Tile({
label,
value,
detail,
footer,
}: {
label: string;
value: string;
detail?: string | null;
footer?: React.ReactNode;
}) {
return (
<div {...stylex.props(styles.tile)}>
<dt {...stylex.props(styles.label)}>{label}</dt>
<dd {...stylex.props(styles.valueRow)}>
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd>
{footer !== undefined && <div {...stylex.props(styles.footer)}>{footer}</div>}
</div>
);
}
export default function StatTiles({ stats }: { stats: StatsTotals }) {
const window = {
mode: "history" as const,
since: stats.since,
until: stats.until,
domain: undefined,
client: undefined,
};
return (
<dl {...stylex.props(styles.grid)}>
<Tile
label="Queries"
value={numberFormat.format(stats.queries)}
footer={
<Link
to="/activity"
search={{ ...window, blocked: undefined }}
{...stylex.props(styles.link, shared.focusRing)}
>
Open in Activity
</Link>
}
/>
<Tile
label="Blocked"
value={numberFormat.format(stats.blocked)}
detail={percentOf(stats.blocked, stats.queries)}
footer={
<Link
to="/activity"
search={{ ...window, blocked: true }}
{...stylex.props(styles.link, shared.focusRing)}
>
Open blocked queries
</Link>
}
/>
<Tile
label="Clients"
value={numberFormat.format(stats.clients)}
footer={
<Link to="/clients" {...stylex.props(styles.link, shared.focusRing)}>
Manage clients
</Link>
}
/>
<Tile
label="Avg response"
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
/>
</dl>
);
}
@@ -52,13 +52,97 @@ export function isEmptyTimeseries(buckets: Bucket[]): boolean {
return buckets.every((bucket) => bucket.queries === 0);
}
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
const plot: Rect = {
function plotRect(width: number, height: number): Rect {
return {
x: MARGIN.left,
y: MARGIN.top,
width: Math.max(0, width - MARGIN.left - MARGIN.right),
height: Math.max(0, height - MARGIN.top - MARGIN.bottom),
};
}
/**
* How many columns share one x-axis label. A 30-day window is 30 columns and a
* 1-hour window is 60, so at narrow widths the labels have to thin out rather
* than overprint each other.
*/
function labelStepFor(count: number, plotWidth: number): number {
if (count === 0 || plotWidth <= 0) return 1;
return Math.max(1, Math.ceil((count * MIN_X_LABEL_PX) / plotWidth));
}
/** One stacked column: the series values in the order the caller stacks them. */
export interface StackedColumn {
ts: number;
total: number;
slot: Rect;
segments: Rect[];
}
export interface StackedLayout {
width: number;
height: number;
plot: Rect;
scaleMax: number;
columns: StackedColumn[];
yTicks: { value: number; y: number }[];
xTicks: { ts: number; x: number }[];
}
/**
* The same geometry as the query-volume chart, for an arbitrary number of
* series. The scale comes from the tallest column's own total, because every
* series here is a disjoint part of the whole rather than a highlighted subset
* of a separately reported total.
*/
export function layoutStacked(
columns: { ts: number; values: number[] }[],
width: number,
height: number,
): StackedLayout {
const plot = plotRect(width, height);
const totals = columns.map((column) => column.values.reduce((sum, value) => sum + value, 0));
const tickValues = niceTicks(Math.max(0, ...totals));
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
const baseline = plot.y + plot.height;
const toHeight = (value: number) => (value / scaleMax) * plot.height;
const slotWidth = columns.length > 0 ? plot.width / columns.length : 0;
const barWidth = Math.max(1, slotWidth - BAR_GAP);
const laidOut: StackedColumn[] = columns.map((column, i) => {
const slotX = plot.x + i * slotWidth;
const barX = slotX + (slotWidth - barWidth) / 2;
let top = baseline;
const segments = column.values.map((value) => {
const segmentHeight = toHeight(value);
top -= segmentHeight;
return { x: barX, y: top, width: barWidth, height: segmentHeight };
});
return {
ts: column.ts,
total: totals[i],
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
segments,
};
});
const step = labelStepFor(columns.length, plot.width);
return {
width,
height,
plot,
scaleMax,
columns: laidOut,
yTicks: tickValues.map((value) => ({ value, y: baseline - toHeight(value) })),
xTicks: laidOut
.filter((_, i) => i % step === 0)
.map((column) => ({ ts: column.ts, x: column.slot.x + column.slot.width / 2 })),
};
}
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
const plot = plotRect(width, height);
const maxQueries = buckets.reduce((max, bucket) => Math.max(max, bucket.queries), 0);
const tickValues = niceTicks(maxQueries);
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
@@ -89,10 +173,7 @@ export function layoutTimeseries(buckets: Bucket[], width: number, height: numbe
const yTicks = tickValues.map((value) => ({ value, y: baseline - toHeight(value) }));
const labelStep =
buckets.length > 0 && plot.width > 0
? Math.max(1, Math.ceil((buckets.length * MIN_X_LABEL_PX) / plot.width))
: 1;
const labelStep = labelStepFor(buckets.length, plot.width);
const xTicks = bars
.filter((_, i) => i % labelStep === 0)
.map((bar) => ({ ts: bar.bucket.ts, x: bar.slot.x + bar.slot.width / 2 }));
@@ -0,0 +1,47 @@
import { layoutDonut, type DonutSlice } from "./donutLayout";
function slice(key: string, value: number): DonutSlice {
return { key, label: key, value, color: "#000000" };
}
test("an empty breakdown has no total and no arcs to draw", () => {
expect(layoutDonut([], 100, 20)).toEqual({ size: 100, total: 0, arcs: [] });
});
test("a breakdown of nothing but zeroes is empty, not a division by zero", () => {
const layout = layoutDonut([slice("a", 0), slice("b", 0)], 100, 20);
expect(layout.total).toBe(0);
expect(layout.arcs).toEqual([]);
});
test("zero-valued entries are dropped rather than legended at 0%", () => {
const layout = layoutDonut([slice("a", 3), slice("b", 0), slice("c", 1)], 100, 20);
expect(layout.arcs.map((arc) => arc.slice.key)).toEqual(["a", "c"]);
expect(layout.total).toBe(4);
});
test("shares are of the drawn total and add up to one", () => {
const layout = layoutDonut([slice("a", 3), slice("b", 1)], 100, 20);
expect(layout.arcs.map((arc) => arc.share)).toEqual([0.75, 0.25]);
});
test("slices keep the order they were ranked in, starting at twelve o'clock", () => {
const layout = layoutDonut([slice("a", 1), slice("b", 1)], 100, 20);
expect(layout.arcs[0].d.startsWith("M 50.000 0.000")).toBe(true);
// The second slice begins where the first ended, half a turn round.
expect(layout.arcs[1].d.startsWith("M 50.000 100.000")).toBe(true);
});
test("a slice over half the ring takes the large-arc flag", () => {
const layout = layoutDonut([slice("a", 9), slice("b", 1)], 100, 20);
expect(layout.arcs[0].d).toContain("A 50 50 0 1 1");
expect(layout.arcs[1].d).toContain("A 50 50 0 0 1");
});
test("a single entry is a closed ring, not a zero-length arc that draws nothing", () => {
const layout = layoutDonut([slice("only", 7)], 100, 20);
expect(layout.arcs).toHaveLength(1);
expect(layout.arcs[0].share).toBe(1);
// Two half arcs out and two back: a lone `A` from a point to itself is a no-op.
expect(layout.arcs[0].d.match(/A /g)).toHaveLength(4);
});
@@ -0,0 +1,95 @@
/**
* Annulus geometry for the two breakdown donuts. Pure, so the arithmetic that
* decides whether a slice closes correctly is testable without a DOM.
*/
export interface DonutSlice {
/** Semantic identity: the React key, the colour key and the legend's identity. */
key: string;
label: string;
/** Disambiguates entries whose labels collide — two "Unknown"s, one name on two route kinds. */
secondary?: string;
value: number;
color: string;
}
export interface DonutArc {
slice: DonutSlice;
/** Of the whole, 0 to 1. */
share: number;
d: string;
}
export interface DonutLayout {
size: number;
total: number;
arcs: DonutArc[];
}
const START_ANGLE = -Math.PI / 2;
function point(center: number, radius: number, angle: number): string {
return `${(center + radius * Math.cos(angle)).toFixed(3)} ${(center + radius * Math.sin(angle)).toFixed(3)}`;
}
/**
* A whole-circle slice cannot be drawn as one arc start and end coincide, and
* the renderer draws nothing at all so the ring is two half arcs.
*/
function fullRing(center: number, outer: number, inner: number): string {
const top = `${center} ${center - outer}`;
const bottom = `${center} ${center + outer}`;
const innerTop = `${center} ${center - inner}`;
const innerBottom = `${center} ${center + inner}`;
return [
`M ${top}`,
`A ${outer} ${outer} 0 0 1 ${bottom}`,
`A ${outer} ${outer} 0 0 1 ${top}`,
`M ${innerTop}`,
`A ${inner} ${inner} 0 0 0 ${innerBottom}`,
`A ${inner} ${inner} 0 0 0 ${innerTop}`,
"Z",
].join(" ");
}
/**
* Slices in the order given the caller has already ranked them starting at
* twelve o'clock and running clockwise. Zero-valued slices are dropped: they
* have no arc to draw, and a legend entry reading 0 is noise.
*/
export function layoutDonut(slices: DonutSlice[], size: number, thickness: number): DonutLayout {
const drawn = slices.filter((slice) => slice.value > 0);
const total = drawn.reduce((sum, slice) => sum + slice.value, 0);
if (total <= 0) return { size, total: 0, arcs: [] };
const center = size / 2;
const outer = center;
const inner = Math.max(0, center - thickness);
if (drawn.length === 1) {
return {
size,
total,
arcs: [{ slice: drawn[0], share: 1, d: fullRing(center, outer, inner) }],
};
}
let angle = START_ANGLE;
const arcs = drawn.map((slice) => {
const share = slice.value / total;
const sweep = share * Math.PI * 2;
const end = angle + sweep;
const large = sweep > Math.PI ? 1 : 0;
const d = [
`M ${point(center, outer, angle)}`,
`A ${outer} ${outer} 0 ${large} 1 ${point(center, outer, end)}`,
`L ${point(center, inner, end)}`,
`A ${inner} ${inner} 0 ${large} 0 ${point(center, inner, angle)}`,
"Z",
].join(" ");
angle = end;
return { slice, share, d };
});
return { size, total, arcs };
}
@@ -0,0 +1,292 @@
/**
* 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.
*/
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";
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;
}
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 json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
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 };
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 };
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;
}),
);
});
afterEach(() => vi.unstubAllGlobals());
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>
);
}
function renderProbe(period: Period = "24h") {
const client = createQueryClient();
const view = render(
<QueryClientProvider client={client}>
<Probe period={period} />
</QueryClientProvider>,
);
return {
rerenderWith: (next: Period) =>
view.rerender(
<QueryClientProvider client={client}>
<Probe period={next} />
</QueryClientProvider>,
),
};
}
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 ?? "";
}
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 () => {
renderProbe();
await waitFor(() => expect(line("totals")).toContain("ready"));
for (const endpoint of OVERVIEW_ENDPOINTS) {
expect(line(endpoint)).toBe(`${endpoint}:ready:24h@${UNTIL}/${SINCE}`);
}
});
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");
renderProbe();
await waitFor(() => expect(line("routes")).toContain("ready"));
expect(calls.routes).toBe(2);
expect(calls.totals).toBe(1);
});
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");
});
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}`));
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"));
});
@@ -0,0 +1,249 @@
/**
* One period, five requests, 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.
*
* 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.
*/
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}`;
}
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 };
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The period Overview is scoped to, as URL state.
*
* One home for the four values and for the rule that turns whatever the URL
* carried into one of them: the route validates with it and the picker offers
* exactly the same list, so a hand-typed `?period=90d` becomes the default
* instead of reaching the API as a parameter it answers 400 to.
*/
import type { Period } from "@/lib/types";
export const PERIODS = ["1h", "24h", "7d", "30d"] as const satisfies readonly Period[];
export const DEFAULT_PERIOD: Period = "24h";
/**
* Undefined rather than the default for anything that is not one of the four,
* so an absent parameter and a nonsense one both leave a clean URL. The default
* is applied where the period is read, not written back into the address bar.
*/
export function parsePeriod(value: unknown): Period | undefined {
return PERIODS.find((period) => period === value);
}
@@ -0,0 +1,73 @@
import { OTHER_KEY, clientKey, qtypeKey, routeKey, seriesColor } from "./seriesColors";
test("the four source-less route kinds and other are fixed, so they mean one thing everywhere", () => {
expect(seriesColor(routeKey("blocked", null))).toBe("#ef4444");
expect(seriesColor(routeKey("cache", null))).toBe("#059669");
expect(seriesColor(routeKey("local", null))).toBe("#8b5cf6");
expect(seriesColor(routeKey("rejected", null))).toBe("#f59e0b");
expect(seriesColor(OTHER_KEY)).toBe("#71717a");
});
test("the colour of a key depends on the key and on nothing else", () => {
// Rank churn and membership churn at once: the panel a poll later is in the
// opposite order, has gained a client and has lost one. Every entry that
// survived keeps its colour, because nothing here reads the set.
const survivors = [clientKey("192.0.2.30"), clientKey("192.0.2.31"), clientKey("192.0.2.32"), OTHER_KEY];
const before = survivors.map(seriesColor);
const after = [clientKey("192.0.2.10"), ...survivors].reverse().map(seriesColor);
for (const [i, key] of survivors.entries()) {
expect(seriesColor(key)).toBe(before[i]);
expect(after).toContain(before[i]);
}
});
test("a panel of realistic entries gets a spread of hues, not one colour repeated", () => {
// The degenerate implementation this refutes: a dynamic branch that returns
// one constant would satisfy every stability test in this file. It also states
// the real cost of hashing without assignment — eight clients come out in five
// hues here, seven query types in four — which is why the donut strokes its
// arcs and the client chart strokes its segments.
const clients = [
"192.0.2.30",
"192.0.2.31",
"192.0.2.32",
"192.0.2.40",
"10.0.0.5",
"10.0.0.6",
"fd00::1",
"laptop.lan",
];
const types = [1, 28, 65, 12, 16, 33];
const routes = ["https://dns.example/dns-query", "https://dns2.example/dns-query", "lan"];
const spreadOf = (keys: string[]) => new Set(keys.map(seriesColor)).size;
expect(spreadOf(clients.map(clientKey))).toBeGreaterThan(1);
expect(spreadOf([...types.map(qtypeKey), qtypeKey(null)])).toBeGreaterThan(1);
expect(spreadOf(routes.map((source) => routeKey("upstream", source)))).toBeGreaterThan(1);
// Half the panel distinct at worst, which is what makes the legend readable
// rather than a list of identical swatches.
expect(spreadOf(clients.map(clientKey))).toBeGreaterThanOrEqual(clients.length / 2);
});
test("a dynamic entry never takes a fixed entry's colour", () => {
// The bug this rules out: a nameless upstream row coming out the same red as
// the Blocked slice beside it in the same ring.
const fixedColors = new Set(["#ef4444", "#059669", "#8b5cf6", "#f59e0b", "#71717a"]);
const keys = [routeKey("upstream", null), routeKey("forward_zone", "lan"), qtypeKey(28), qtypeKey(null)];
for (const key of keys) expect(fixedColors.has(seriesColor(key))).toBe(false);
});
test("the same name under two route kinds is two identities", () => {
expect(routeKey("upstream", "lan")).not.toBe(routeKey("forward_zone", "lan"));
});
test("two upstreams are two identities: the pair, not the route kind, is the key", () => {
expect(routeKey("upstream", "https://dns.example/dns-query")).not.toBe(
routeKey("upstream", "https://dns2.example/dns-query"),
);
});
test("a null qtype is its own entry rather than folded into a real one", () => {
expect(qtypeKey(null)).not.toBe(qtypeKey(1));
expect(seriesColor(qtypeKey(null))).toBe(seriesColor(qtypeKey(null)));
});
@@ -0,0 +1,84 @@
/**
* A colour per thing, not per position.
*
* Every series and slice on Overview is ranked by count, and a rank that changes
* between two thirty-second polls would recolour the whole panel if colour came
* from the ordinal. So colour keys on the entry's semantic identity: the qtype
* value, the client string, or for routes the full `(route, source)` pair,
* because keying on the route kind alone would paint two adjacent upstream
* slices the same and merge them into one shape.
*
* The four source-less route kinds and the "other" bucket are fixed rather than
* hashed: they mean the same thing on every install, and Blocked and Cache
* already have colours on the query-volume timeline.
*/
import type { RouteKind } from "@/lib/types";
/**
* The dynamic hues, validated for CVD separation and 3:1 contrast against both
* surfaces; the same hex in light and dark, as the timeline's series are. The
* five fixed colours below are deliberately not in here: a nameless upstream row
* must not come out the same red as Blocked in the ring beside it.
*/
const PALETTE = ["#3b82f6", "#ec4899", "#14b8a6", "#f97316", "#6366f1", "#84cc16", "#06b6d4", "#a855f7"] as const;
const FIXED: Record<string, string> = {
"route:blocked": "#ef4444",
"route:cache": "#059669",
"route:local": "#8b5cf6",
"route:rejected": "#f59e0b",
other: "#71717a",
};
/** The identity of everything outside the top eight clients. */
export const OTHER_KEY = "other";
export function qtypeKey(qtype: number | null): string {
return qtype === null ? "qtype:none" : `qtype:${qtype}`;
}
export function clientKey(client: string): string {
return `client:${client}`;
}
/**
* Upstream and forward-zone rows are identified by their source as well as their
* kind; the other four kinds have no source and collapse to the kind alone, so
* they land on their fixed colour.
*/
export function routeKey(route: RouteKind, source: string | null): string {
return source === null ? `route:${route}` : `route:${route}:${source}`;
}
/** FNV-1a, 32-bit: stable across reloads and across browsers, which is the whole point. */
function hash(key: string): number {
let value = 0x811c9dc5;
for (let i = 0; i < key.length; i += 1) {
value ^= key.charCodeAt(i);
value = Math.imul(value, 0x01000193);
}
return value >>> 0;
}
/**
* The colour of one key, and of nothing else.
*
* This is a pure function of the identity: no panel, no key set, no rank. That
* is the property the page needs, because the panels churn a client enters the
* top eight and another leaves it every few polls and an assignment that read
* the whole set would repaint entries that did not change at all.
*
* The cost is that a hash is not injective: two entries of one panel can come
* out the same hue. That is a real cost and it is the smaller one. Resolving it
* by probing would mean the entries that lost a slot depend on which entries
* were present, which is the churn this exists to prevent and eight hues
* cannot colour nine things distinctly in any case. The failure a shared hue
* would cause instead, two neighbouring slices merging into one shape, is
* prevented where it happens: the donut strokes every arc and the client chart
* strokes every segment in the surface colour, so equal hues still read as two.
* The legend and the hidden table name every entry either way.
*/
export function seriesColor(key: string): string {
return FIXED[key] ?? PALETTE[hash(key) % PALETTE.length];
}
@@ -0,0 +1,287 @@
/**
* The Pause control, migrated from the header PauseWidget it replaces. Every
* behaviour that widget pinned is pinned here, now driven by `Health.protection`
* rather than by a second poll of `/api/pause`.
*/
import { act } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import PauseControl from "@/features/pause/PauseControl";
import { formatClock } from "@/lib/format";
import { createQueryClient } from "@/lib/queryClient";
import { health } from "@/lib/healthFixture";
import { queryKeys } from "@/lib/queries";
import type { Health, PausePost, PauseState } from "@/lib/types";
let protection: Health["protection"];
let postBodies: PausePost[];
let postFailure: (() => Response) | null;
let healthFails: boolean;
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
protection = { state: "active", until: null };
postBodies = [];
postFailure = null;
healthFails = false;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/health") {
if (healthFails) {
return new Response(JSON.stringify({ error: "nope" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
return jsonResponse(health({ protection }));
}
if (url === "/api/pause" && init?.method === "POST") {
const body = JSON.parse(String(init.body)) as PausePost;
postBodies.push(body);
if (postFailure !== null) return postFailure();
protection = body.paused
? {
state: "paused",
until:
body.duration_seconds == null
? null
: Math.floor(Date.now() / 1000) + body.duration_seconds,
}
: { state: "active", until: null };
const echo: PauseState = { paused: body.paused, until: protection.until };
return jsonResponse(echo);
}
return jsonResponse({ error: "not stubbed" });
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
function renderControl(client?: QueryClient) {
render(
<QueryClientProvider client={client ?? createQueryClient()}>
<PauseControl />
</QueryClientProvider>,
);
}
async function findPauseTrigger(): Promise<HTMLButtonElement> {
await waitFor(() => {
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
expect(button.disabled).toBe(false);
});
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
}
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
renderControl();
const trigger = await findPauseTrigger();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
expect(screen.getByRole("button", { name: label })).toBeTruthy();
}
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
await screen.findByRole("button", { name: "Resume" });
});
test("indefinite pause sends no duration_seconds", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
await screen.findByRole("button", { name: "Resume" });
});
test("resume posts paused false and returns to the Pause button", async () => {
protection = { state: "paused", until: null };
renderControl();
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
await screen.findByRole("button", { name: "Pause" });
});
test("a timed pause says until when, beside the control that would end it", () => {
const nowSec = Math.floor(Date.now() / 1000);
const client = createQueryClient();
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: nowSec + 90 } }));
renderControl(client);
// The same clock format the Diagnostics health strip writes, off the same
// reading, so the two cannot say different things about one pause.
expect(screen.getByText(`Paused until ${formatClock(nowSec + 90)}`)).toBeTruthy();
expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy();
});
test("a pause with no end says so without inventing a time", () => {
const client = createQueryClient();
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: null } }));
renderControl(client);
expect(screen.getByText("Paused")).toBeTruthy();
expect(screen.queryByText(/until/)).toBeNull();
});
test("an active resolver states nothing: the button already says Pause", async () => {
renderControl();
expect(await findPauseTrigger()).toBeTruthy();
expect(screen.queryByText(/^Paused/)).toBeNull();
});
test("escape closes the duration menu", async () => {
renderControl();
const trigger = await findPauseTrigger();
fireEvent.click(trigger);
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
});
test("failed pause with 429 shows a ticking retry countdown", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
postFailure = () =>
new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "30" },
});
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
act(() => {
vi.advanceTimersByTime(1000);
});
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
});
test("failed pause with 503 shows the degraded message", async () => {
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
});
test("a successful pause clears the previous mutation error", async () => {
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("alert");
postFailure = null;
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("button", { name: "Resume" });
expect(screen.queryByRole("alert")).toBeNull();
});
test("no control at all while protection is unavailable", async () => {
protection = { state: "unavailable", until: null };
const client = createQueryClient();
renderControl(client);
await waitFor(() => expect(client.getQueryData(queryKeys.health)).toBeDefined());
expect(screen.queryByRole("button")).toBeNull();
});
test("a failed poll after a good one withdraws the control rather than acting on a stale state", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
await vi.waitFor(() =>
expect((screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement).disabled).toBe(false),
);
healthFails = true;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
vi.useRealTimers();
});
test("a menu left open when the control withdraws does not come back open", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
protection = { state: "unavailable", until: null };
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
protection = { state: "active", until: null };
await vi.advanceTimersByTimeAsync(11_000);
// Protection is back, and so is the trigger — but the menu is a thing the
// reader opened, and nobody opened this one.
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers();
});
test("a menu open when someone else pauses does not reopen when that pause ends", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
// Filtering is paused from somewhere else, and this browser learns it from
// the poll. The Resume rendering has no menu.
protection = { state: "paused", until: null };
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy());
protection = { state: "active", until: null };
await vi.advanceTimersByTimeAsync(11_000);
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers();
});
test("protection unknown offers no control at all, by the same rule as unavailable", () => {
// Health has not answered. Which of Pause and Resume applies is exactly what
// it has not said, so the control names neither.
renderControl();
expect(screen.queryByRole("button")).toBeNull();
});
+181
View File
@@ -0,0 +1,181 @@
/**
* The Pause/Resume control, in the two places a pause is a valid answer to what
* the reader is looking at: the foot of the sidebar, where it belongs to the
* resolver rather than to any page, and beside the detail of a query that was
* blocked.
*
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
* the Diagnostics health strip, and it renders nothing at all while protection
* is unavailable or unknown pausing a resolver that has no filter snapshot
* would change nothing an operator could observe, and a state health has not
* confirmed does not name an action either.
*
* A pause says so, wherever the control is. "Resume" alone names an action
* without stating the state it would end, and with the header indicator and the
* Overview status row both gone the sidebar is the only place most pages can
* carry that fact at all: a paused resolver would otherwise leave no trace
* outside the Diagnostics page. The line and the health strip cannot disagree
* one `protection` reading, one clock format, and the expiry refetch in
* `useProtection` retires both at the same moment. An active resolver gets no
* line: the button says Pause, which is the whole message.
*/
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatClock } from "@/lib/format";
import { pauseMutation } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { useProtection } from "./protection";
const DURATIONS = [
{ label: "60 seconds", seconds: 60 },
{ label: "5 minutes", seconds: 300 },
{ label: "30 minutes", seconds: 1800 },
{ label: "Indefinitely", seconds: null },
] as const;
const styles = stylex.create({
/**
* Disabled text darkens in light scheme and lightens in dark, the opposite
* direction from `textMuted`, so the token cannot express it.
*/
trigger: {
color: {
default: null,
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
},
row: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "0.25rem",
},
/** Text, never a colour or an icon alone: this is the state, spelled out. */
state: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
left: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export default function PauseControl() {
const queryClient = useQueryClient();
const protection = useProtection();
const mutation = useMutation(pauseMutation(queryClient));
const [menuOpen, setMenuOpen] = useState(false);
const paused = protection.state === "paused";
const { reset } = mutation;
useEffect(() => reset(), [paused, reset]);
// Nothing to offer, by the same rule in both cases: unavailable has no action
// worth taking, and unknown has no way to tell which of the two it would be.
// A disabled "Pause" beside a reading that says "Paused until 14:05" names
// the wrong action, which is the contradiction this control exists to end.
const actionable = protection.state === "active" || protection.state === "paused";
// Leaving `active` unmounts the menu but not the state that opened it, and
// the menu belongs to the active rendering alone — a pause someone else
// started, seen through the poll, takes it away exactly as a withdrawal does.
// Closing on the way out rather than on the way back means the trigger can
// only ever come back shut, however long it was gone.
useEffect(() => {
if (protection.state !== "active") setMenuOpen(false);
}, [protection.state]);
if (!actionable) return null;
if (paused) {
const until = protection.state === "paused" ? protection.until : null;
return (
<div {...stylex.props(styles.row)}>
<p {...stylex.props(styles.state)}>
{until === null ? "Paused" : `Paused until ${formatClock(until)}`}
</p>
<button
type="button"
onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Resume
</button>
<InlineError error={mutation.error} />
</div>
);
}
return (
<div
{...stylex.props(styles.anchor)}
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
>
<button
type="button"
aria-expanded={menuOpen}
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Pause
</button>
{menuOpen && (
<div id="pause-menu" {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<button
key={label}
type="button"
onClick={() => {
setMenuOpen(false);
mutation.mutate(
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
>
{label}
</button>
))}
</div>
)}
<InlineError error={mutation.error} />
</div>
);
}
@@ -1,194 +0,0 @@
import { act } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import PauseWidget, { formatRemaining } from "@/features/pause/PauseWidget";
import { createQueryClient } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queries";
import type { PausePost, PauseState } from "@/lib/types";
let getState: PauseState;
let postBodies: PausePost[];
let postResponse: (body: PausePost) => PauseState;
let postFailure: (() => Response) | null;
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
postBodies = [];
postFailure = null;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url !== "/api/pause") return jsonResponse({ error: "not stubbed" });
if (init?.method === "POST") {
const body = JSON.parse(String(init.body)) as PausePost;
postBodies.push(body);
if (postFailure !== null) return postFailure();
return jsonResponse(postResponse(body));
}
return jsonResponse(getState);
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
function renderWidget(client?: QueryClient) {
render(
<QueryClientProvider client={client ?? createQueryClient()}>
<PauseWidget />
</QueryClientProvider>,
);
}
async function findPauseTrigger(): Promise<HTMLButtonElement> {
await waitFor(() => {
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
expect(button.disabled).toBe(false);
});
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
}
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
getState = { paused: false, until: null };
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
renderWidget();
const trigger = await findPauseTrigger();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
expect(screen.getByRole("button", { name: label })).toBeTruthy();
}
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
await screen.findByRole("button", { name: "Resume" });
expect(screen.getByText(/^Paused \d+:\d{2}$/)).toBeTruthy();
});
test("indefinite pause sends no duration_seconds and renders without a countdown", async () => {
getState = { paused: false, until: null };
postResponse = () => ({ paused: true, until: null });
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
await screen.findByRole("button", { name: "Resume" });
expect(screen.getByText("Paused")).toBeTruthy();
});
test("resume posts paused false and returns to the Pause button", async () => {
getState = { paused: true, until: null };
postResponse = () => ({ paused: false, until: null });
renderWidget();
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
await screen.findByRole("button", { name: "Pause" });
});
test("timed pause counts down live", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const nowSec = Math.floor(Date.now() / 1000);
const client = createQueryClient();
client.setQueryData(queryKeys.pause, { paused: true, until: nowSec + 90 });
renderWidget(client);
expect(screen.getByText("Paused 1:30")).toBeTruthy();
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByText("Paused 1:28")).toBeTruthy();
});
test("escape closes the duration menu", async () => {
getState = { paused: false, until: null };
postResponse = () => getState;
renderWidget();
const trigger = await findPauseTrigger();
fireEvent.click(trigger);
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
});
test("failed pause with 429 shows a ticking retry countdown", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
getState = { paused: false, until: null };
postFailure = () =>
new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "30" },
});
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
act(() => {
vi.advanceTimersByTime(1000);
});
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
});
test("failed pause with 503 shows the degraded message", async () => {
getState = { paused: false, until: null };
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
});
test("a successful pause clears the previous mutation error", async () => {
getState = { paused: false, until: null };
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("alert");
postFailure = null;
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("button", { name: "Resume" });
expect(screen.queryByRole("alert")).toBeNull();
});
test("formatRemaining renders m:ss and h:mm:ss and clamps at zero", () => {
expect(formatRemaining(0)).toBe("0:00");
expect(formatRemaining(-5)).toBe("0:00");
expect(formatRemaining(59)).toBe("0:59");
expect(formatRemaining(90)).toBe("1:30");
expect(formatRemaining(3661)).toBe("1:01:01");
});
-187
View File
@@ -1,187 +0,0 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DURATIONS = [
{ label: "60 seconds", seconds: 60 },
{ label: "5 minutes", seconds: 300 },
{ label: "30 minutes", seconds: 1800 },
{ label: "Indefinitely", seconds: null },
] as const;
const styles = stylex.create({
/**
* Disabled text darkens in light scheme and lightens in dark, the opposite
* direction from `textMuted`, so the token cannot express it.
*/
trigger: {
color: {
default: null,
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
},
pausedRow: {
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
},
pausedControls: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
/**
* Amber as standalone text on the app ground, not inside a warning banner, so
* the `warn*` tokens tuned against `warnSurface` do not apply here.
*/
pausedLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(55.5% 0.163 48.998)",
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
},
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
right: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export function formatRemaining(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds);
const hours = Math.floor(clamped / 3600);
const minutes = Math.floor((clamped % 3600) / 60);
const seconds = clamped % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function useNowSeconds(active: boolean): number {
const [now, setNow] = useState(nowSeconds);
useEffect(() => {
if (!active) return;
setNow(nowSeconds());
const id = setInterval(() => setNow(nowSeconds()), 1000);
return () => clearInterval(id);
}, [active]);
return now;
}
export default function PauseWidget() {
const queryClient = useQueryClient();
const { data } = useQuery({
...pauseQuery(),
refetchInterval: (query) => (query.state.data?.paused === true ? 5000 : false),
});
const mutation = useMutation(pauseMutation(queryClient));
const [menuOpen, setMenuOpen] = useState(false);
const now = useNowSeconds(data?.paused === true && data.until !== null);
const paused = data?.paused === true;
const { reset } = mutation;
useEffect(() => reset(), [paused, reset]);
if (data === undefined) {
return (
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
Pause
</button>
);
}
if (data.paused) {
return (
<div {...stylex.props(styles.pausedRow)}>
<div {...stylex.props(styles.pausedControls)}>
<span {...stylex.props(styles.pausedLabel)}>
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
</span>
<button
type="button"
onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Resume
</button>
</div>
<InlineError error={mutation.error} />
</div>
);
}
return (
<div
{...stylex.props(styles.anchor)}
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
>
<button
type="button"
aria-expanded={menuOpen}
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Pause
</button>
{menuOpen && (
<div id="pause-menu" {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<button
key={label}
type="button"
onClick={() => {
setMenuOpen(false);
mutation.mutate(
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
>
{label}
</button>
))}
</div>
)}
<InlineError error={mutation.error} />
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Protection, as the interface reads it: one view model derived from
* `Health.protection`, shared by the sidebar Pause control, the related action
* on a blocked query's detail, and the Diagnostics health strip.
*
* There is deliberately no second source. `GET /api/pause` answers the same
* question, but two polled copies of one fact can disagree, and the row that
* says "Paused" beside a button that says "Pause" is exactly the contradiction
* this milestone set out to remove.
*/
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { healthQuery, queryKeys } from "@/lib/queries";
import type { Health } from "@/lib/types";
/**
* `unknown` is its own state, never folded into `active`: health has not
* answered yet, or the last poll failed, and claiming filtering is in force on
* no evidence is the one reading that could get a household bitten.
*/
export type ProtectionView =
{ state: "unknown" } | { state: "active" } | { state: "paused"; until: number | null } | { state: "unavailable" };
export function protectionViewOf(protection: Health["protection"] | undefined): ProtectionView {
if (protection === undefined) return { state: "unknown" };
if (protection.state === "unavailable") return { state: "unavailable" };
if (protection.state === "paused") return { state: "paused", until: protection.until };
return { state: "active" };
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
/**
* Protection now, with the expiry of a timed pause scheduled.
*
* Health polls every ten seconds, so without the timer a pause that ended
* three seconds ago still reads "Paused until 14:05" a stale claim about the
* one fact the indicator exists to state. The timeout fires a second past
* `until` so the server's own expiry rule, not the browser's clock, decides.
*/
export function useProtection(): ProtectionView {
const queryClient = useQueryClient();
const { data, isError } = useQuery(healthQuery());
// A failed poll leaves the last body in the cache, and that body is a
// reading, not the current state: the query this view answers is "is
// filtering in force right now", and a stale yes is the same lie as an
// invented one. The Diagnostics health strip may still render the cached
// conditions, because it marks them stale in the same breath; this view has
// no such caption, so a failure is `unknown`.
const view = isError ? ({ state: "unknown" } as const) : protectionViewOf(data?.protection);
const until = view.state === "paused" ? view.until : null;
useEffect(() => {
if (until === null) return;
const delay = Math.max(0, until - nowSeconds() + 1) * 1000;
const timer = setTimeout(() => void queryClient.invalidateQueries({ queryKey: queryKeys.health }), delay);
return () => clearTimeout(timer);
}, [until, queryClient]);
return view;
}
@@ -99,7 +99,7 @@ test("renders the upstream table and the add form", async () => {
expect(disabledToggle.checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
expect(screen.getByText(/reflects the running pool/)).toBeTruthy();
expect(screen.getByText(/counts the running pool/)).toBeTruthy();
});
test("adding an upstream posts every field and raises the restart banner", async () => {
@@ -97,8 +97,8 @@ export default function UpstreamsPage() {
<section>
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
<p {...stylex.props(styles.intro)}>
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
health table on the Dashboard reflects the running pool, not this list.
The pool builds its clients at startup, so an edit here takes effect at the next restart. The Upstreams
row on Overview counts the running pool, not this list.
</p>
{upstreams.length === 0 ? (
+12 -4
View File
@@ -33,11 +33,13 @@ import type {
SettingsEnvelope,
SettingsPatch,
SourceStatus,
StatsClients,
StatsRoutes,
StatsTimeseries,
StatsTotals,
StatsTypes,
Upstream,
UpstreamEcho,
UpstreamHealth,
UpstreamInput,
Version,
} from "@/lib/types";
@@ -122,13 +124,14 @@ export const liveQueriesUrl = "/api/queries/live";
export const getStats = (period?: Period): Promise<StatsTotals> => request(`/api/stats${qs({ period })}`);
export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
request(`/api/stats/timeseries${qs({ period })}`);
export const getStatsTypes = (period?: Period): Promise<StatsTypes> => request(`/api/stats/types${qs({ period })}`);
export const getStatsRoutes = (period?: Period): Promise<StatsRoutes> => request(`/api/stats/routes${qs({ period })}`);
export const getStatsClients = (period?: Period): Promise<StatsClients> =>
request(`/api/stats/clients${qs({ period })}`);
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
export const getUpstreamHealth = (period?: Period): Promise<UpstreamHealth> =>
request(`/api/upstream/health${qs({ period })}`);
// Diagnostics
export const getDiagnostics = (filter: DiagnosticsFilter = {}): Promise<DiagnosticsPage> =>
@@ -235,6 +238,11 @@ export const deleteUpstream = (id: number): Promise<void> => request(`/api/upstr
// Pause + settings
/**
* The pause state on its own. Protection is read from `/api/health` everywhere
* the interface shows it one source, one story so this is left for the live
* stream's session probe, which wants the cheapest authenticated GET there is.
*/
export const getPause = (): Promise<PauseState> => request("/api/pause");
export const postPause = (body: PausePost): Promise<PauseState> => request("/api/pause", { method: "POST", body });
+111 -33
View File
@@ -34,11 +34,13 @@ import type {
RuleEcho,
SettingsEnvelope,
SourceStatus,
StatsClients,
StatsRoutes,
StatsTimeseries,
StatsTotals,
StatsTypes,
Upstream,
UpstreamEcho,
UpstreamHealth,
Version,
} from "@/lib/types";
@@ -49,21 +51,24 @@ export const sample_get_health: Health = {
state: "recording",
},
disk: {
db_bytes: 0,
free_bytes: 0,
log_bytes: 0,
sample_failures: 0,
state: "ok",
},
queries_dropped: 0,
refreshes_gated: 0,
snapshot_generation: 0,
protection: {
state: "active",
until: null,
},
query_history: {
dropped_total: 0,
last_drop_s: null,
state: "recording",
},
status: "ok",
upstreams: {
available: 0,
state: "ok",
total: 0,
},
writer_failed: false,
};
export const sample_get_version: Version = {
@@ -391,30 +396,6 @@ export const sample_update_upstream: UpstreamEcho = {
url: "https://dns.example/dns-query",
};
export const sample_get_upstream_health: UpstreamHealth = {
available: 0,
complete: true,
period: "24h",
since: 0,
total: 0,
until: 0,
upstreams: [
{
available: true,
enabled: true,
period: {
attempts: 0,
failures: 0,
last_failure_at: null,
last_failure_error: null,
success_rate: null,
successes: 0,
},
url: "https://dns.example/dns-query",
},
],
};
export const sample_get_queries: QueriesPage = {
coverage: {
available_since: 0,
@@ -543,7 +524,6 @@ export const sample_get_query_detail: QueryDetail = {
export const sample_get_stats: StatsTotals = {
avg_response_time_us: null,
blocked: 0,
cached: 0,
clients: 0,
coverage: {
available_since: 0,
@@ -846,6 +826,104 @@ export const sample_error_not_found: ErrorEnvelope = {
error: "not found",
};
export const sample_get_stats_types: StatsTypes = {
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
since: 0,
types: [
{
count: 0,
qtype: 0,
},
{
count: 0,
qtype: null,
},
],
until: 0,
};
export const sample_get_stats_routes: StatsRoutes = {
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
routes: [
{
count: 0,
route: "upstream",
source: "https://dns.example/dns-query",
},
{
count: 0,
route: "blocked",
source: null,
},
{
count: 0,
route: "cache",
source: null,
},
{
count: 0,
route: "forward_zone",
source: "lan",
},
{
count: 0,
route: "local",
source: null,
},
{
count: 0,
route: "rejected",
source: null,
},
{
count: 0,
route: "upstream",
source: "https://dns2.example/dns-query",
},
{
count: 0,
route: "upstream",
source: null,
},
],
since: 0,
until: 0,
};
export const sample_get_stats_clients: StatsClients = {
bucket_seconds: 0,
clients: [
{
buckets: [0],
client: "192.0.2.30",
},
{
buckets: [0],
client: "192.0.2.31",
},
{
buckets: [0],
client: "192.0.2.32",
},
],
coverage: {
available_since: 0,
complete: true,
},
other: [0],
period: "1h",
since: 0,
until: 0,
};
export const sample_error_unauthorized: ErrorEnvelope = {
error: "authentication required",
};
+8 -1
View File
@@ -1,10 +1,17 @@
import { formatBytes, formatDuration, formatMicros, formatTime } from "@/lib/format";
import { formatBytes, formatClock, formatDuration, formatMicros, formatTime } from "@/lib/format";
test("formatTime renders unix seconds in the given locale and zone", () => {
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
expect(formatTime(1704067200, "en-US", "UTC").replace(//g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
});
test("formatClock states the time of day alone, for a stamp read against now", () => {
expect(formatClock(Date.UTC(2026, 0, 1, 14, 5) / 1000, "en-GB", "UTC")).toBe("14:05");
expect(formatClock(Date.UTC(2026, 0, 1, 9, 30) / 1000, "en-GB", "UTC")).toBe("09:30");
// No date: the caller places it against now, and a date would be noise.
expect(formatClock(Date.UTC(2026, 0, 1, 14, 5) / 1000, "en-GB", "UTC")).not.toMatch(/2026/);
});
test("formatBytes humanizes with binary units", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(1023)).toBe("1023 B");
+11
View File
@@ -7,6 +7,17 @@ export function formatTime(unixSeconds: number, locale?: string, timeZone?: stri
}).format(new Date(unixSeconds * 1000));
}
/**
* Unix seconds the time of day alone, "14:05". For a stamp the reader places
* against now a pause that ends shortly, the last row that was dropped
* where the date would be noise on every reading but one.
*/
export function formatClock(unixSeconds: number, locale?: string, timeZone?: string): string {
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone }).format(
new Date(unixSeconds * 1000),
);
}
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
export function formatBytes(bytes: number): string {
+19
View File
@@ -0,0 +1,19 @@
/**
* A healthy `GET /api/health` body, for tests that need protection to be a
* settled fact rather than the subject under test. Overrides are per condition,
* so a test names only the one it is about.
*/
import type { Health } from "./types";
export function health(overrides: Partial<Health> = {}): Health {
return {
status: "ok",
protection: { state: "active", until: null },
upstreams: { state: "ok", available: 2, total: 2 },
query_history: { state: "recording", dropped_total: 0, last_drop_s: null },
diagnostics: { state: "recording", active_warnings: 0, active_errors: 0 },
disk: { state: "ok", free_bytes: 40 * 1024 * 1024 * 1024 },
...overrides,
};
}
+29 -17
View File
@@ -24,13 +24,15 @@ export const queryKeys = {
version: ["version"] as const,
stats: (period: Period) => ["stats", period] as const,
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
statsTypes: (period: Period) => ["stats", "types", period] as const,
statsRoutes: (period: Period) => ["stats", "routes", period] as const,
statsClients: (period: Period) => ["stats", "clients", period] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
queryDetail: (id: number) => ["queries", "detail", id] as const,
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
diagnostic: (id: number) => ["diagnostics", "event", id] as const,
/** Prefix of every diagnostics entry, page and detail alike; the purge target. */
diagnosticsAll: ["diagnostics"] as const,
upstreamHealth: (period: Period) => ["upstream-health", period] as const,
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
/** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */
lookupAll: ["lookup"] as const,
@@ -43,7 +45,6 @@ export const queryKeys = {
clients: ["clients"] as const,
clientPrefixes: ["client-prefixes"] as const,
upstreams: ["upstreams"] as const,
pause: ["pause"] as const,
settings: ["settings"] as const,
};
@@ -63,6 +64,27 @@ export const timeseriesQuery = (period: Period = "24h") =>
refetchInterval: 30_000,
});
export const statsTypesQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.statsTypes(period),
queryFn: () => api.getStatsTypes(period),
refetchInterval: 30_000,
});
export const statsRoutesQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.statsRoutes(period),
queryFn: () => api.getStatsRoutes(period),
refetchInterval: 30_000,
});
export const statsClientsQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.statsClients(period),
queryFn: () => api.getStatsClients(period),
refetchInterval: 30_000,
});
// Keyset pagination on `next_before` (handlers/queries.zig). A background
// refetch replays every page in cursor order, so newly logged rows shift the
// whole window instead of opening a gap between page 1 and page 2.
@@ -101,16 +123,6 @@ export const diagnosticsInfiniteQuery = (filter: DiagnosticsFilter = {}, enabled
export const diagnosticQuery = (id: number) =>
queryOptions({ queryKey: queryKeys.diagnostic(id), queryFn: () => api.getDiagnostic(id) });
// The period is part of the key: the upstream aggregates are ranged like the
// stats ones, so the picker has to refetch them rather than reuse a cached
// window under a new label.
export const upstreamHealthQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.upstreamHealth(period),
queryFn: () => api.getUpstreamHealth(period),
refetchInterval: 30_000,
});
export const lookupQuery = (domain: string, groupId?: number) =>
queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) });
@@ -136,8 +148,6 @@ export const clientPrefixesQuery = () =>
export const upstreamsQuery = () => queryOptions({ queryKey: queryKeys.upstreams, queryFn: api.listUpstreams });
export const pauseQuery = () => queryOptions({ queryKey: queryKeys.pause, queryFn: api.getPause });
export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings, queryFn: api.getSettings });
// Mutation option factories. Usage: useMutation(groupCreateMutation(useQueryClient())).
@@ -322,11 +332,13 @@ export const upstreamDeleteMutation = (qc: QueryClient) => ({
onSuccess: () => invalidateUpstreams(qc),
});
// Protection is a health condition, and health is the only thing that reads it:
// the sidebar control, the Diagnostics health strip and the related action on a
// blocked query all render `Health.protection`. Without this invalidation they
// would contradict a successful mutation until the next ten-second poll.
export const pauseMutation = (qc: QueryClient) => ({
mutationFn: (body: PausePost) => api.postPause(body),
onSuccess: (state: Awaited<ReturnType<typeof api.postPause>>) => {
qc.setQueryData(queryKeys.pause, state);
},
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.health }),
});
export const settingsPutMutation = (qc: QueryClient) => ({
+86 -47
View File
@@ -11,23 +11,39 @@ export interface ErrorEnvelope {
error: string;
}
/**
* The five conditions `GET /api/health` reports, and a `status` computed from
* exactly their states. Nothing degrades the rollup without appearing here, so
* a reader of this object can always name what degraded the box.
*/
export interface Health {
status: "ok" | "degraded";
disk: {
state: "ok" | "warn" | "critical";
free_bytes: number;
db_bytes: number;
log_bytes: number;
sample_failures: number;
/**
* Is filtering in force. `unavailable` is not an operator's doing: it is the
* state in which the query path has no filter snapshot to evaluate against.
* It outranks a pause, which is why `until` is null under it.
*/
protection: {
state: "active" | "paused" | "unavailable";
/** The second filtering resumes at; null for an indefinite pause and for every other state. */
until: number | null;
};
upstreams: {
state: "ok" | "unavailable";
available: number;
/** Enabled upstreams: the pool is built from those alone. */
total: number;
};
queries_dropped: number;
writer_failed: boolean;
refreshes_gated: number;
snapshot_generation: number | null;
/**
* Whether Activity can be trusted. `dropped_total` is cumulative and does not
* decide the state a drop an hour ago is not a fault now.
*/
query_history: {
state: "recording" | "losing" | "failed";
dropped_total: number;
/** The newest drop; stamped by a separate atomic, so it can lag a non-zero count. */
last_drop_s: number | null;
};
/**
* The diagnostics store's own state, not a summary of what it holds:
* `unavailable` means the store is missing or its last write failed, so the
@@ -38,6 +54,11 @@ export interface Health {
active_warnings: number;
active_errors: number;
};
/** `low` is the monitor's `warn` renamed at the wire: warn reads as a log level. */
disk: {
state: "ok" | "low" | "critical";
free_bytes: number;
};
}
export interface Version {
@@ -276,7 +297,6 @@ export interface StatsTotals {
until: number;
queries: number;
blocked: number;
cached: number;
clients: number;
avg_response_time_us: number | null;
coverage: Coverage;
@@ -298,6 +318,61 @@ export interface StatsTimeseries {
coverage: Coverage;
}
/**
* One DNS type's share of the window. `qtype` is the numeric code as logged:
* naming it is the admin's job (`features/queries/qtype.ts`), and a row whose
* type was never recorded keeps its own `null` group rather than disappearing.
*/
export interface StatsTypeRow {
qtype: number | null;
count: number;
}
export interface StatsTypes {
period: Period;
since: number;
until: number;
types: StatsTypeRow[];
coverage: Coverage;
}
/**
* How the window's queries were answered. `source` names the answering upstream
* on `upstream` rows and the zone on `forward_zone` rows; every other route kind
* carries null, as does a row whose identity was not recorded.
*/
export interface StatsRouteRow {
route: RouteKind;
source: string | null;
count: number;
}
export interface StatsRoutes {
period: Period;
since: number;
until: number;
routes: StatsRouteRow[];
coverage: Coverage;
}
/** One client's per-bucket counts, aligned to `StatsTimeseries`'s buckets. */
export interface StatsClientSeries {
client: string;
buckets: number[];
}
export interface StatsClients {
period: Period;
since: number;
until: number;
bucket_seconds: number;
/** The eight busiest clients in the window, ranked by total count. */
clients: StatsClientSeries[];
/** Everything outside the top eight. Always present and always bucket-count-sized. */
other: number[];
coverage: Coverage;
}
export interface LookupResult {
domain: string;
group_id: number;
@@ -310,42 +385,6 @@ export interface LookupResult {
safe_search_rewrite: string | null;
}
export interface UpstreamPeriodStats {
attempts: number;
successes: number;
failures: number;
/** successes/attempts, 0 to 1; null when attempts is 0 — no observations is not perfect reliability. */
success_rate: number | null;
/** The newest failure inside the window, unix seconds; null when the window holds none. */
last_failure_at: number | null;
/** The error name belonging to last_failure_at; null exactly when it is. */
last_failure_error: string | null;
}
export interface UpstreamHealthEntry {
url: string;
/** Live configuration, not history. */
enabled: boolean;
/** Live state; false while the upstream is backing off. */
available: boolean;
period: UpstreamPeriodStats;
}
export interface UpstreamHealth {
period: Period;
since: number;
until: number;
available: number;
total: number;
/**
* No capacity drops known in this process within the selected window; up to about a minute of
* the newest outcomes may not have flushed yet, and outcomes lost in an unclean shutdown are
* not detectable.
*/
complete: boolean;
upstreams: UpstreamHealthEntry[];
}
export interface Group {
id: number;
name: string;
+63 -20
View File
@@ -5,6 +5,7 @@ import {
createRoute,
createRouter,
lazyRouteComponent,
redirect,
useRouter,
type ErrorComponentProps,
type RouterHistory,
@@ -34,11 +35,15 @@ import {
queryDetailQuery,
rulesQuery,
settingsQuery,
statsClientsQuery,
statsQuery,
statsRoutesQuery,
statsTypesQuery,
timeseriesQuery,
upstreamHealthQuery,
upstreamsQuery,
} from "@/lib/queries";
import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period";
import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -130,20 +135,51 @@ const shellRoute = createRoute({
component: AppShell,
});
const dashboardRoute = createRoute({
/**
* The landing default, not a compatibility alias: Overview is where the app
* opens, and `/` is spelled out rather than left as a second name for it.
*/
const indexRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/",
// allSettled, not all: DashboardPage reads these with useQuery so each widget
// can render its own error. A rejecting loader would replace the whole page
// with RouteError and take the three healthy widgets down with the failed one.
loader: ({ context }) =>
Promise.allSettled([
context.queryClient.ensureQueryData(statsQuery("24h")),
context.queryClient.ensureQueryData(timeseriesQuery("24h")),
context.queryClient.ensureQueryData(healthQuery()),
context.queryClient.ensureQueryData(upstreamHealthQuery()),
]),
component: lazyRouteComponent(() => import("@/features/dashboard/DashboardPage")),
beforeLoad: () => {
throw redirect({ to: "/overview" });
},
});
/**
* Overview. The period is the whole of its applied state, so a view of the page
* is a link: a hand-typed or stale value falls back to the default rather than
* reaching the API as a parameter it answers 400 to.
*/
const overviewRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/overview",
validateSearch: (search: Record<string, unknown>): { period?: Period } => ({
period: parsePeriod(search["period"]),
}),
loaderDeps: ({ search }): { period: Period } => ({ period: search.period ?? DEFAULT_PERIOD }),
/**
* Started here, awaited nowhere. Every panel reads these with `useQuery` and
* owns its own loading and error surface, so awaiting would trade that whole
* contract for one blocking navigation: the page would sit on the slowest of
* five requests and then appear complete, instead of the four that answered
* rendering beside the one still in flight. The rejections are caught only to
* keep them from going unhandled; the panels state them.
*/
loader: ({ context, deps }) => {
const start = (promise: Promise<unknown>) => void promise.catch(() => {});
start(context.queryClient.ensureQueryData(healthQuery()));
start(context.queryClient.ensureQueryData(statsQuery(deps.period)));
start(context.queryClient.ensureQueryData(timeseriesQuery(deps.period)));
start(context.queryClient.ensureQueryData(statsClientsQuery(deps.period)));
// The registered names the client chart labels its series with. Started here
// so the lookup is not a second round trip after the page chunk lands.
start(context.queryClient.ensureQueryData(clientsQuery()));
start(context.queryClient.ensureQueryData(statsTypesQuery(deps.period)));
start(context.queryClient.ensureQueryData(statsRoutesQuery(deps.period)));
},
component: lazyRouteComponent(() => import("@/features/overview/OverviewPage")),
});
/**
@@ -301,14 +337,20 @@ const diagnosticsRoute = createRoute({
since: search.since,
until: search.until,
}),
// allSettled: the two sections render their own state, and the resolved
// history failing must not replace the active list with the error page.
/**
* Started, not awaited, as Overview's is: the strip and the two lists each
* render their own loading and error state, and the health strip's whole
* contract begins with a visible loading state it would never reach if the
* route held the page back until the reading arrived.
*/
loader: ({ context, deps }) => {
const base = diagnosticsFilterOf(deps);
return Promise.allSettled([
context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "active" })),
context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "resolved" })),
]);
void context.queryClient.ensureQueryData(healthQuery()).catch(() => {});
for (const state of ["active", "resolved"] as const) {
void context.queryClient
.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state }))
.catch(() => {});
}
},
component: lazyRouteComponent(() => import("@/features/diagnostics/DiagnosticsPage")),
});
@@ -334,7 +376,8 @@ const settingsRoute = createRoute({
const routeTree = rootRoute.addChildren([
loginRoute,
shellRoute.addChildren([
dashboardRoute,
indexRoute,
overviewRoute,
activityRoute,
activityDetailRoute,
activityTestRoute,
+160 -53
View File
@@ -1,12 +1,15 @@
import { fireEvent, render, screen } from "@testing-library/react";
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, resetAuthProbeForTests } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { formatClock } from "@/lib/format";
import { health } from "@/lib/healthFixture";
import type { Health } from "@/lib/types";
const NAV_LABELS = [
"Dashboard",
"Overview",
"Activity",
"Clients",
"Groups",
@@ -25,7 +28,6 @@ const RESPONSES: Record<string, unknown> = {
until: 86400,
queries: 0,
blocked: 0,
cached: 0,
clients: 0,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
@@ -38,27 +40,50 @@ const RESPONSES: Record<string, unknown> = {
buckets: [],
coverage: { complete: true, available_since: 0 },
},
"/api/health": {
status: "ok",
disk: { state: "ok", free_bytes: 0, db_bytes: 0, log_bytes: 0, sample_failures: 0 },
upstreams: { available: 1, total: 1 },
queries_dropped: 0,
writer_failed: false,
refreshes_gated: 0,
snapshot_generation: null,
diagnostics: { state: "recording", active_warnings: 0, active_errors: 0 },
"/api/stats/clients?period=24h": {
period: "24h",
since: 0,
until: 86400,
bucket_seconds: 1800,
clients: [],
other: [],
coverage: { complete: true, available_since: 0 },
},
"/api/upstream/health": { upstreams: [], available: 1, total: 1 },
"/api/stats/types?period=24h": {
period: "24h",
since: 0,
until: 86400,
types: [],
coverage: { complete: true, available_since: 0 },
},
"/api/stats/routes?period=24h": {
period: "24h",
since: 0,
until: 86400,
routes: [],
coverage: { complete: true, available_since: 0 },
},
"/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } },
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */
let healthBody: Health | null;
function stubFetch(extra: (url: string) => Response | null = () => null) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const override = extra(url);
if (override !== null) return override;
if (url === "/api/health") {
const failed = healthBody === null;
return new Response(JSON.stringify(failed ? { error: "health unavailable" } : healthBody), {
status: failed ? 503 : 200,
headers: { "content-type": "application/json" },
});
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
@@ -67,13 +92,9 @@ beforeEach(() => {
});
}),
);
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("shell renders the dashboard route with all nav links", async () => {
function renderShell() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
@@ -83,8 +104,30 @@ test("shell renders the dashboard route with all nav links", async () => {
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
await screen.findByRole("heading", { name: "Dashboard" });
function diagnosticsBadgeText(): string | null {
const link = screen.getAllByRole("link", { name: /^Diagnostics/ })[0];
const badge = link.querySelector("[aria-label]");
return badge === null ? null : (badge.textContent ?? "");
}
beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
healthBody = health();
stubFetch();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("shell renders the overview route with all nav links", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav).toBeTruthy();
@@ -94,41 +137,105 @@ test("shell renders the dashboard route with all nav links", async () => {
});
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/auth/login")
return new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
});
if (url === "/api/auth/logout")
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "retry-after": "7" },
});
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
stubFetch((url) => {
if (url === "/api/auth/login")
return new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}),
);
if (url === "/api/auth/logout")
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "retry-after": "7" },
});
return null;
});
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
renderShell();
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
await screen.findByText("Rate limited. Try again in 7s.");
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
});
test("the header carries no protection display at all any more", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
for (const gone of [/^Protection/, /^Paused/]) {
expect(screen.queryByRole("link", { name: gone })).toBeNull();
}
});
test("Pause sits at the foot of the sidebar, above the version label", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const pause = await waitFor(() => within(aside).getByRole("button", { name: "Pause" }));
const version = within(aside).getByText(/^nxdns v/);
// Node order, not styling: the control precedes the version footer.
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("the mobile drawer carries the same control, not a header one it lost", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(1));
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
// Both renderings are mounted; the viewport decides which is painted.
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(2));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
const pause = within(drawer).getByRole("button", { name: "Pause" });
const version = within(drawer).getByText(/^nxdns v/);
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
// The trace a pause leaves on every page. With the header indicator and the
// Overview status row both gone, a reader who is not on Diagnostics has only
// this line to tell them filtering is off.
const until = Math.floor(Date.now() / 1000) + 90;
healthBody = health({ protection: { state: "paused", until } });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
await waitFor(() => expect(within(aside).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
await waitFor(() => expect(within(drawer).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
});
test("nothing open and a healthy rollup leaves the Diagnostics item unbadged", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(document.querySelector("aside")?.textContent).toContain("Diagnostics"));
expect(diagnosticsBadgeText()).toBeNull();
});
test("open episodes are counted on the nav item", async () => {
healthBody = health({ diagnostics: { state: "recording", active_warnings: 1, active_errors: 2 } });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("3"));
expect(screen.getAllByLabelText("3 active diagnostic events").length).toBeGreaterThan(0);
});
test("a degraded rollup with nothing open is still marked, and a failed poll too", async () => {
healthBody = health({ status: "degraded" });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
expect(screen.getAllByLabelText("Health degraded").length).toBeGreaterThan(0);
});
test("a health poll that failed is marked unknown rather than left looking healthy", async () => {
healthBody = null;
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
expect(screen.getAllByLabelText("Health unavailable").length).toBeGreaterThan(0);
});
+59 -9
View File
@@ -4,8 +4,9 @@ import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError";
import { versionQuery } from "@/lib/queries";
import PauseWidget from "../features/pause/PauseWidget";
import { healthQuery, versionQuery } from "@/lib/queries";
import PauseControl from "@/features/pause/PauseControl";
import { diagnosticsBadge } from "./diagnosticsBadge";
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
import RestartBanner from "../features/settings/RestartBanner";
import { styles as shared } from "@/ui/styles";
@@ -16,7 +17,7 @@ const WIDE = "@media (min-width: 768px)";
const DARK = "@media (prefers-color-scheme: dark)";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard" },
{ to: "/overview", label: "Overview" },
{ to: "/activity", label: "Activity" },
{ to: "/clients", label: "Clients" },
{ to: "/groups", label: "Groups" },
@@ -35,12 +36,40 @@ const styles = stylex.create({
gap: "0.25rem",
},
navLink: {
display: "block",
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderRadius: "0.25rem",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textDecorationLine: "none",
},
navLabel: {
flex: 1,
},
/**
* Neutral chrome: the mark is the message, and a coloured pill here would be
* the page's loudest element on every route. Text and shape carry it.
*/
badge: {
minWidth: "1.25rem",
borderRadius: "0.625rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceHover,
paddingInline: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1.125rem",
fontWeight: 500,
textAlign: "center",
color: colors.textSecondary,
},
/** The control sits with the footer, not in the scrolling nav list above it. */
sidebarFooter: {
paddingInline: "1rem",
paddingTop: "0.75rem",
},
/** The current page reads as a filled chip, heavier than the hover fill. */
navActive: {
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
@@ -135,6 +164,8 @@ const styles = stylex.create({
});
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const health = useQuery(healthQuery());
const badge = diagnosticsBadge(health.data, health.isError);
return (
<ul {...stylex.props(styles.navList)}>
{NAV_ITEMS.map((item) => (
@@ -142,7 +173,6 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
<Link
to={item.to}
onClick={onNavigate}
activeOptions={{ exact: item.to === "/" }}
activeProps={{
"aria-current": "page",
className: stylex.props(styles.navActive).className,
@@ -150,7 +180,12 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
inactiveProps={{ className: stylex.props(styles.navIdle).className }}
{...stylex.props(styles.navLink, shared.focusRing)}
>
{item.label}
<span {...stylex.props(styles.navLabel)}>{item.label}</span>
{item.to === "/diagnostics" && badge !== null && (
<span aria-label={badge.label} {...stylex.props(styles.badge)}>
{badge.text}
</span>
)}
</Link>
</li>
))}
@@ -158,6 +193,22 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
);
}
/**
* The sidebar's foot, in both renderings. Pause is a runtime action on the whole
* resolver rather than on the page in front of the reader, which is why it sits
* with the version label instead of in the header of every route.
*/
function SidebarFooter() {
return (
<>
<div {...stylex.props(styles.sidebarFooter)}>
<PauseControl />
</div>
<VersionFooter />
</>
);
}
function VersionFooter() {
const { data } = useQuery(versionQuery());
return (
@@ -201,7 +252,7 @@ export default function AppShell() {
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
<NavLinks />
</nav>
<VersionFooter />
<SidebarFooter />
</aside>
<div {...stylex.props(styles.column)}>
<header {...stylex.props(styles.header)}>
@@ -216,7 +267,6 @@ export default function AppShell() {
</button>
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div {...stylex.props(styles.headerRight)}>
<PauseWidget />
<LogoutButton />
</div>
</header>
@@ -227,7 +277,7 @@ export default function AppShell() {
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
<NavLinks onNavigate={() => setDrawerOpen(false)} />
</nav>
<VersionFooter />
<SidebarFooter />
</div>
)}
<main {...stylex.props(styles.main)}>
+38
View File
@@ -0,0 +1,38 @@
import { health } from "@/lib/healthFixture";
import { diagnosticsBadge } from "./diagnosticsBadge";
test("a healthy box with nothing open wears no badge", () => {
expect(diagnosticsBadge(health(), false)).toBeNull();
});
test("open episodes are the count, warnings and errors together", () => {
const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 2, active_errors: 1 } }),
false,
);
expect(badge).toEqual({ text: "3", label: "3 active diagnostic events" });
});
test("one open episode is counted in the singular", () => {
const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 0, active_errors: 1 } }),
false,
);
expect(badge).toEqual({ text: "1", label: "1 active diagnostic event" });
});
test("a degraded rollup with no open episode still shows, so no degraded state is invisible", () => {
const badge = diagnosticsBadge(health({ status: "degraded" }), false);
expect(badge).toEqual({ text: "!", label: "Health degraded" });
});
test("a failed poll is not evidence of health, badge and all", () => {
// Cached body says everything is fine; the poll that would have confirmed it
// never landed. An unbadged item here claims health on no evidence.
expect(diagnosticsBadge(health(), true)).toEqual({ text: "!", label: "Health unavailable" });
expect(diagnosticsBadge(undefined, true)).toEqual({ text: "!", label: "Health unavailable" });
});
test("the first poll being in flight is the one unknown that hides", () => {
expect(diagnosticsBadge(undefined, false)).toBeNull();
});
+37
View File
@@ -0,0 +1,37 @@
/**
* The count beside the Diagnostics nav item.
*
* Three things have to be visible and only one of them is a number. Open
* episodes are the count. A `degraded` rollup with no open episode still has to
* show something, or a degraded box looks exactly like a healthy one. And a
* health poll that failed is not evidence of health: it shows the same neutral
* mark, because the alternative is an unbadged item claiming all is well on no
* evidence at all.
*
* "Fresh" here means the latest poll succeeded, never TanStack's `isStale`:
* healthQuery's `staleTime` is 0, so staleness is true in every gap between
* polls and would badge the item permanently.
*/
import type { Health } from "@/lib/types";
export interface NavBadge {
/** What the badge shows. Shape and text, never colour alone. */
text: string;
/** What a screen reader hears in its place. */
label: string;
}
export function diagnosticsBadge(health: Health | undefined, pollFailed: boolean): NavBadge | null {
// The one hidden unknown, and only because it is momentary: the first poll has
// not answered yet, and there is nothing to be right or wrong about.
if (health === undefined && !pollFailed) return null;
if (pollFailed) return { text: "!", label: "Health unavailable" };
if (health === undefined) return null;
const open = health.diagnostics.active_warnings + health.diagnostics.active_errors;
if (open > 0) {
return { text: String(open), label: `${open} active diagnostic ${open === 1 ? "event" : "events"}` };
}
if (health.status === "degraded") return { text: "!", label: "Health degraded" };
return null;
}