milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+87
-6
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user