From 037f209179423e947b879456e20737c3f80d2b3e Mon Sep 17 00:00:00 2001 From: m5r Date: Thu, 20 Aug 2026 20:05:59 +0200 Subject: [PATCH] =?UTF-8?q?milestone=2027:=20diagnostics=20=E2=80=94=20ope?= =?UTF-8?q?rational=20failures=20land=20in=20one=20curated=20log,=20resolv?= =?UTF-8?q?ed=20history=20purgeable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + PLAN.md | 20 + .../features/dashboard/DashboardPage.test.tsx | 1 + .../diagnostics/DiagnosticDetailPage.test.tsx | 218 +++ .../diagnostics/DiagnosticDetailPage.tsx | 226 +++ .../diagnostics/DiagnosticsPage.test.tsx | 339 ++++ .../features/diagnostics/DiagnosticsPage.tsx | 498 ++++++ .../features/diagnostics/SeverityBadge.tsx | 42 + .../features/diagnostics/eventCopy.test.ts | 50 + admin/src/features/diagnostics/eventCopy.ts | 155 ++ admin/src/lib/api.ts | 18 + admin/src/lib/contractSamples.gen.ts | 59 + admin/src/lib/format.test.ts | 11 +- admin/src/lib/format.ts | 12 + admin/src/lib/queries.ts | 41 + admin/src/lib/types.ts | 81 + admin/src/routes.tsx | 51 + admin/src/shell/AppShell.test.tsx | 2 + admin/src/shell/AppShell.tsx | 1 + docs/reference/api.md | 8 +- specs/milestone-27.md | 285 ++++ specs/ui-redesign.md | 304 ++++ src/app.zig | 350 +++- src/cli.zig | 12 +- src/config/import.zig | 50 + src/filter/filter_integration_test.zig | 161 +- src/filter/manager.zig | 652 +++++++- src/server/cert_store.zig | 140 +- src/server/client_names.zig | 106 +- src/server/clients.zig | 118 ++ src/storage/config_schema.zig | 102 +- src/storage/disk_monitor.zig | 181 ++- src/storage/events.zig | 1427 +++++++++++++++++ src/storage/events_fixture.zig | 47 + src/storage/logger.zig | 118 ++ src/storage/migrations.zig | 117 +- src/storage/phase6_integration_test.zig | 6 +- src/storage/querylog_schema.zig | 69 +- src/storage/repositories/events_repo.zig | 1177 ++++++++++++++ src/storage/retention.zig | 168 +- src/tests.zig | 4 + src/upstream/history.zig | 82 + src/upstream/pool.zig | 97 ++ src/web/handlers/diagnostics.zig | 448 ++++++ src/web/handlers/health.zig | 115 +- src/web/metrics.zig | 96 ++ src/web/openapi.yaml | 223 ++- src/web/routes.zig | 13 +- src/web/server.zig | 6 + src/web/web_integration_test.zig | 199 +++ 50 files changed, 8608 insertions(+), 102 deletions(-) create mode 100644 admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx create mode 100644 admin/src/features/diagnostics/DiagnosticDetailPage.tsx create mode 100644 admin/src/features/diagnostics/DiagnosticsPage.test.tsx create mode 100644 admin/src/features/diagnostics/DiagnosticsPage.tsx create mode 100644 admin/src/features/diagnostics/SeverityBadge.tsx create mode 100644 admin/src/features/diagnostics/eventCopy.test.ts create mode 100644 admin/src/features/diagnostics/eventCopy.ts create mode 100644 specs/milestone-27.md create mode 100644 specs/ui-redesign.md create mode 100644 src/storage/events.zig create mode 100644 src/storage/events_fixture.zig create mode 100644 src/storage/repositories/events_repo.zig create mode 100644 src/web/handlers/diagnostics.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b4fe8..798b674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Sections are written by hand. Nothing here is generated from commit messages: th ## [Unreleased] +### Added + +- **A diagnostics page.** Operational failures now land in one curated log instead of only journald: blocklist download failures, certificate reload failures, disk pressure, query-log writer and maintenance failures, upstream exchange and history failures, client tracking failures, listener and configuration problems at boot, and the query-log recreation an upgrade causes. One entry per failing subject — an entry opens on the first failure, counts repeats, and closes itself when the subject recovers; nothing needs dismissing. Each entry says what it means for the service and what to do about it. `GET /api/diagnostics` serves the log, `GET /api/health` reports the active counts and degrades while the diagnostics store itself cannot write, and `/metrics` gains `nxdns_diagnostics_active_warnings`, `nxdns_diagnostics_active_errors` and `nxdns_diagnostics_write_failures_total`. Resolved entries can be purged when you decide the history has served its purpose — one entry from its row or its detail page, or the whole resolved history at once with "Purge all resolved" (`DELETE /api/diagnostics/{id}` and `DELETE /api/diagnostics`). An entry that is still failing is the current state of the box, not history, so it has no purge action and the API answers 409. + ### Fixed - **An upstream success rate no longer rounds up to 100.0% while failures stand.** One decimal place cannot hold 12,696 successes out of 12,698 attempts: it rounded to `100.0%`, so the row claimed perfect reliability next to a failure count of 2. Neither end of the scale is reachable by rounding any more — `100.0%` needs an actual absence of failures and `0.0%` an actual absence of successes, and a rate a hair off either end shows `99.9%` or `0.1%` instead. diff --git a/PLAN.md b/PLAN.md index cbd7e47..d8830a7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -435,8 +435,28 @@ CREATE TABLE forward_zones ( ); CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + +CREATE TABLE operational_events ( + id INTEGER PRIMARY KEY, + code TEXT NOT NULL, + subject_key TEXT NOT NULL, + subject_label TEXT NOT NULL, + severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')), + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + occurrences INTEGER NOT NULL CHECK (occurrences > 0), + resolved_at INTEGER, + detail TEXT NOT NULL DEFAULT '', + CHECK (resolved_at IS NULL OR resolved_at >= first_seen) +); +CREATE UNIQUE INDEX idx_operational_events_active + ON operational_events(code, subject_key) WHERE resolved_at IS NULL; +CREATE INDEX idx_operational_events_last_seen + ON operational_events(last_seen DESC); ``` +`operational_events` is the one table here that is **not** configuration. It is the diagnostics log of `src/storage/events.zig`: one row per failure episode, opened on the first failure and resolved when the same subject succeeds again. It is deliberately absent from `config_schema.table_names` and `config_schema.delete_order`, so `nxdns export` never emits it and `nxdns import` never wipes it. + ### 11.3 querylog.db Schema ```sql diff --git a/admin/src/features/dashboard/DashboardPage.test.tsx b/admin/src/features/dashboard/DashboardPage.test.tsx index 425a1d2..2a755ca 100644 --- a/admin/src/features/dashboard/DashboardPage.test.tsx +++ b/admin/src/features/dashboard/DashboardPage.test.tsx @@ -61,6 +61,7 @@ const RESPONSES: Record = { 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", diff --git a/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx b/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx new file mode 100644 index 0000000..cbf684f --- /dev/null +++ b/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx @@ -0,0 +1,218 @@ +import { cleanup, 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 { DIAGNOSTIC_CODES, type DiagnosticEvent } from "@/lib/types"; +import { EVENT_COPY } from "./eventCopy"; + +const NOW_S = Math.floor(Date.now() / 1000); + +function event(overrides: Partial = {}): DiagnosticEvent { + return { + id: 42, + code: "blocklist.refresh", + component: "blocklist", + subject: "StevenBlack", + severity: "warning", + first_seen: NOW_S - 7200, + last_seen: NOW_S - 600, + occurrences: 4, + resolved_at: null, + detail: "download failed: ConnectionTimedOut", + ...overrides, + }; +} + +/** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */ +const NO_CONTENT = Symbol("204"); + +let responses: Record; +let requested: string[]; + +beforeEach(() => { + requested = []; + responses = { + "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, + }; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => { + const url = String(input); + const method = init?.method ?? "GET"; + const key = method === "GET" ? url : `${method} ${url}`; + requested.push(key); + const payload = responses[key]; + if (payload === undefined) + return new Response(JSON.stringify({ error: "no such event" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + if (payload === NO_CONTENT) return new Response(null, { status: 204 }); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** + * `retry` is off in the failure test: the shared client backs 5xx off for + * seconds, which the render assertions would sit through for nothing. + */ +function renderDetail(id: number, { retry = true } = {}) { + const queryClient = createQueryClient(); + if (!retry) { + const defaults = queryClient.getDefaultOptions(); + queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } }); + } + const router = createAppRouter(createMemoryHistory({ initialEntries: [`/diagnostics/${id}`] }), queryClient); + render( + + + + + , + ); + return router; +} + +test("an open episode shows its facts, its copy and the error the server sent", async () => { + responses["/api/diagnostics/42"] = event(); + renderDetail(42); + + await screen.findByRole("heading", { name: "Blocklist source failed to update" }); + expect(screen.getByText("Warning")).toBeTruthy(); + expect(screen.getByText("StevenBlack")).toBeTruthy(); + expect(screen.getByText("Active for 2h")).toBeTruthy(); + expect(screen.getByText("Not yet — still failing")).toBeTruthy(); + expect(screen.getByText("4")).toBeTruthy(); + expect(screen.getByText("blocklist.refresh")).toBeTruthy(); + expect(screen.getByText(EVENT_COPY["blocklist.refresh"].impact)).toBeTruthy(); + expect(screen.getByText(EVENT_COPY["blocklist.refresh"].remediation)).toBeTruthy(); + expect(screen.getByText("download failed: ConnectionTimedOut")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Go to Blocklists" }).getAttribute("href")).toBe("/blocklists"); +}); + +test("a resolved episode states how long it lasted, not how long it has run", async () => { + responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 }); + renderDetail(7); + + await screen.findByRole("heading", { name: "Blocklist source failed to update" }); + expect(screen.getByText("Resolved after 1h")).toBeTruthy(); + expect(screen.queryByText("Not yet — still failing")).toBeNull(); +}); + +test("an open episode offers no purge", async () => { + responses["/api/diagnostics/42"] = event(); + renderDetail(42); + + await screen.findByRole("heading", { name: "Blocklist source failed to update" }); + expect(screen.queryByRole("button", { name: "Purge" })).toBeNull(); +}); + +test("purging a resolved episode asks first, then returns to the list", async () => { + responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 }); + responses["DELETE /api/diagnostics/7"] = NO_CONTENT; + responses["/api/diagnostics?state=active"] = { events: [], next_before: null, active: { warnings: 0, errors: 0 } }; + responses["/api/diagnostics?state=resolved"] = { + events: [], + next_before: null, + active: { warnings: 0, errors: 0 }, + }; + const router = renderDetail(7); + + await screen.findByRole("heading", { name: "Blocklist source failed to update" }); + fireEvent.click(screen.getByRole("button", { name: "Purge" })); + + const dialog = await screen.findByRole("alertdialog"); + expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good."); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(requested).not.toContain("DELETE /api/diagnostics/7"); + + fireEvent.click(screen.getByRole("button", { name: "Purge" })); + fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" })); + + await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/7")); + // The row it was showing no longer exists, so the page it navigates to is + // the list rather than a 404 of its own. + await waitFor(() => expect(router.state.location.pathname).toBe("/diagnostics")); +}); + +test("a refused purge stays on the event and shows why", async () => { + responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => { + if (init?.method === "DELETE") + return new Response(JSON.stringify({ error: "the event is still active" }), { + status: 409, + headers: { "content-type": "application/json" }, + }); + return new Response(JSON.stringify(responses[String(input)] ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), + ); + const router = renderDetail(7, { retry: false }); + + await screen.findByRole("heading", { name: "Blocklist source failed to update" }); + fireEvent.click(screen.getByRole("button", { name: "Purge" })); + fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" })); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("the event is still active"); + expect(router.state.location.pathname).toBe("/diagnostics/7"); +}); + +test("every code renders its own title, impact and remediation", async () => { + for (const [index, code] of DIAGNOSTIC_CODES.entries()) { + const id = 100 + index; + responses[`/api/diagnostics/${id}`] = event({ id, code, component: code.slice(0, code.indexOf(".")) }); + renderDetail(id); + + const copy = EVENT_COPY[code]; + await screen.findByRole("heading", { name: copy.title }); + expect(screen.getByText(copy.impact), code).toBeTruthy(); + expect(screen.getByText(copy.remediation), code).toBeTruthy(); + screen.getByText(code); + cleanup(); + } +}); + +test("an event retention has removed shows the server's message, not an empty page", async () => { + renderDetail(999); + await screen.findByText("no such event"); + expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy(); +}); + +test("an unavailable store reports the failure instead of loading forever", async () => { + responses["/api/diagnostics/42"] = event(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => + String(input).startsWith("/api/diagnostics/") + ? new Response(JSON.stringify({ error: "store unavailable" }), { + status: 503, + headers: { "content-type": "application/json" }, + }) + : new Response(JSON.stringify(responses[String(input)] ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + renderDetail(42, { retry: false }); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("The server is starting or degraded."); + expect(screen.queryByText("Loading event…")).toBeNull(); + expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy(); +}); diff --git a/admin/src/features/diagnostics/DiagnosticDetailPage.tsx b/admin/src/features/diagnostics/DiagnosticDetailPage.tsx new file mode 100644 index 0000000..56aa5f0 --- /dev/null +++ b/admin/src/features/diagnostics/DiagnosticDetailPage.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import * as stylex from "@stylexjs/stylex"; +import InlineError from "@/lib/InlineError"; +import { formatDuration, formatTime } from "@/lib/format"; +import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries"; +import ConfirmDialog from "@/ui/ConfirmDialog"; +import { styles as shared } from "@/ui/styles"; +import { colors } from "@/ui/tokens.stylex"; +import SeverityBadge from "./SeverityBadge"; +import { componentLabel, copyFor } from "./eventCopy"; + +const styles = stylex.create({ + back: { + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.primaryOnSurface, + textDecorationLine: "none", + }, + headingRow: { + marginTop: "0.5rem", + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: "0.5rem", + }, + heading: { + fontSize: "1.5rem", + lineHeight: "2rem", + fontWeight: 600, + }, + purgeAction: { + marginInlineStart: "auto", + }, + subject: { + marginTop: "0.25rem", + color: colors.textSecondary, + wordBreak: "break-all", + }, + panel: { + marginTop: "1rem", + maxWidth: "48rem", + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + backgroundColor: colors.surfaceRaised, + padding: "1rem", + }, + facts: { + display: "grid", + gap: "0.5rem 1rem", + gridTemplateColumns: { + default: "auto", + "@media (min-width: 640px)": "max-content 1fr", + }, + margin: 0, + fontSize: "0.875rem", + lineHeight: "1.25rem", + }, + term: { + color: colors.textMuted, + }, + value: { + margin: 0, + }, + sectionHeading: { + marginTop: "1.5rem", + fontSize: "1.125rem", + lineHeight: "1.75rem", + fontWeight: 600, + }, + prose: { + marginTop: "0.5rem", + maxWidth: "48rem", + fontSize: "0.875rem", + lineHeight: "1.5rem", + }, + detail: { + marginTop: "0.5rem", + maxWidth: "48rem", + overflowX: "auto", + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + padding: "0.75rem", + fontSize: "0.8125rem", + lineHeight: "1.25rem", + whiteSpace: "pre-wrap", + wordBreak: "break-all", + }, + links: { + marginTop: "1rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + }, + link: { + color: colors.primaryOnSurface, + }, + loading: { + marginTop: "1rem", + color: colors.textMuted, + }, +}); + +export default function DiagnosticDetailPage() { + const { id } = useParams({ from: "/shell/diagnostics/$id" }); + const eventId = Number(id); + const { data, error, isPending, refetch } = useQuery(diagnosticQuery(eventId)); + + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const purge = useMutation(diagnosticPurgeMutation(queryClient)); + const [confirming, setConfirming] = useState(false); + + function confirmPurge() { + setConfirming(false); + // The row this page is about is gone, so staying here would show the + // 404 the purge itself caused. + purge.mutate(eventId, { onSuccess: () => void navigate({ to: "/diagnostics" }) }); + } + + if (isPending) { + return ( +

+ Loading event… +

+ ); + } + if (data === undefined) { + return ( +
+ + ← All diagnostics + + void refetch()} /> +
+ ); + } + + const copy = copyFor(data.code); + const resolvedAt = data.resolved_at; + const span = (resolvedAt ?? Math.floor(Date.now() / 1000)) - data.first_seen; + + return ( +
+ + ← All diagnostics + +
+

{copy.title}

+ + {/* Only history can be purged: an open episode is the current state of the box. */} + {resolvedAt !== null && ( + + )} +
+

{data.subject}

+ + +
+
+
State
+
+ {resolvedAt === null + ? `Active for ${formatDuration(span)}` + : `Resolved after ${formatDuration(span)}`} +
+
First seen
+
{formatTime(data.first_seen)}
+
Last seen
+
{formatTime(data.last_seen)}
+
Occurrences
+
{data.occurrences}
+
Resolved
+
+ {data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)} +
+
Component
+
{componentLabel(data.component)}
+
Code
+
{data.code}
+
+
+ +

Impact

+

{copy.impact}

+ +

What to do

+

{copy.remediation}

+ +

Last error

+ {data.detail === "" ? ( +

The server recorded no error text for this event.

+ ) : ( +
{data.detail}
+ )} + + {copy.link !== undefined && ( +

+ + Go to {copy.link.label} + +

+ )} + + setConfirming(false)} + /> +
+ ); +} diff --git a/admin/src/features/diagnostics/DiagnosticsPage.test.tsx b/admin/src/features/diagnostics/DiagnosticsPage.test.tsx new file mode 100644 index 0000000..d46c426 --- /dev/null +++ b/admin/src/features/diagnostics/DiagnosticsPage.test.tsx @@ -0,0 +1,339 @@ +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 type { DiagnosticEvent, DiagnosticsPage } from "@/lib/types"; + +// Ages are rendered against the wall clock, so the fixtures are anchored to it +// rather than to a frozen instant: faking time here would fight the query +// client's own timers for no gain. +const NOW_S = Math.floor(Date.now() / 1000); + +function event(id: number, overrides: Partial = {}): DiagnosticEvent { + return { + id, + code: "blocklist.refresh", + component: "blocklist", + subject: "StevenBlack", + severity: "warning", + first_seen: NOW_S - 3600, + last_seen: NOW_S - 300, + occurrences: 3, + resolved_at: null, + detail: "download failed: ConnectionTimedOut", + ...overrides, + }; +} + +function page(events: DiagnosticEvent[], nextBefore: number | null = null): DiagnosticsPage { + return { events, next_before: nextBefore, active: { warnings: 1, errors: 1 } }; +} + +const ACTIVE = page([ + event(42), + event(41, { + code: "upstream.exchange", + component: "upstream", + subject: "tls://dns.example:853", + severity: "error", + occurrences: 1, + }), +]); + +const RESOLVED = page([ + event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }), +]); + +/** A stubbed response that carries a non-200 status instead of a payload. */ +class Failure { + constructor( + readonly status: number, + readonly body: unknown, + ) {} +} + +function fail(status: number, message: string): Failure { + return new Failure(status, { error: message }); +} + +/** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */ +const NO_CONTENT = Symbol("204"); + +let responses: Record; +let requested: string[]; + +beforeEach(() => { + requested = []; + responses = { + "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, + "/api/diagnostics?state=active": ACTIVE, + "/api/diagnostics?state=resolved": RESOLVED, + }; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => { + const url = String(input); + const method = init?.method ?? "GET"; + // Reads stay keyed by url alone, so the assertions below read as the + // request line they are; writes carry their method. + const key = method === "GET" ? url : `${method} ${url}`; + requested.push(key); + const payload = responses[key]; + if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); + if (payload === NO_CONTENT) return new Response(null, { status: 204 }); + if (payload instanceof Failure) { + return new Response(JSON.stringify(payload.body), { + status: payload.status, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** + * `retry` is off in the failure tests: the shared client backs 5xx off for + * seconds, which the render assertions would sit through for nothing. + */ +function renderRoute(path = "/diagnostics", { retry = true } = {}) { + const queryClient = createQueryClient(); + if (!retry) { + const defaults = queryClient.getDefaultOptions(); + queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } }); + } + const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient); + render( + + + + + , + ); + return router; +} + +/** A RAC Select names its trigger with the current value and then the label. */ +function trigger(label: string): HTMLElement { + return screen.getByRole("button", { name: new RegExp(`${label}$`) }); +} + +async function pick(label: string, option: string) { + fireEvent.click(trigger(label)); + fireEvent.click(await screen.findByRole("option", { name: option })); + await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); +} + +test("active episodes come first, each with its title, subject, age and count", async () => { + renderRoute(); + await screen.findByRole("heading", { name: "Diagnostics" }); + + const active = screen.getByText("Blocklist source failed to update").closest("li")!; + expect(within(active).getByText("Warning")).toBeTruthy(); + expect(within(active).getByText("StevenBlack")).toBeTruthy(); + expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy(); + + const failing = screen.getByText("Upstream failing").closest("li")!; + expect(within(failing).getByText("Error")).toBeTruthy(); + expect(within(failing).getByText(/1 occurrence(?!s)/)).toBeTruthy(); + + // The resolved history is a separate section, below the active list. + const table = within(screen.getByRole("table")); + expect(table.getByText("Disk space low")).toBeTruthy(); + expect(screen.getByText(/Showing 1 resolved entry — end of history/)).toBeTruthy(); +}); + +test("nothing open reads as good news, not as a broken page", async () => { + responses["/api/diagnostics?state=active"] = page([]); + renderRoute(); + await screen.findByRole("heading", { name: "Diagnostics" }); + + const healthy = await screen.findByText("No active operational issues."); + expect(healthy.getAttribute("role")).toBe("status"); + // Quiet: no alert anywhere on the page, and no empty table standing in. + expect(screen.queryByRole("alert")).toBeNull(); +}); + +test("a filter lands in the url and refetches both sections through it", async () => { + responses["/api/diagnostics?severity=error&state=active"] = page([ + event(41, { code: "upstream.exchange", component: "upstream", severity: "error" }), + ]); + responses["/api/diagnostics?severity=error&state=resolved"] = page([]); + const router = renderRoute(); + await screen.findByRole("heading", { name: "Diagnostics" }); + + await pick("Severity", "Errors"); + + await waitFor(() => expect(router.state.location.search).toEqual({ severity: "error" })); + await waitFor(() => expect(screen.queryByText("Blocklist source failed to update")).toBeNull()); + expect(requested).toContain("/api/diagnostics?severity=error&state=active"); + expect(requested).toContain("/api/diagnostics?severity=error&state=resolved"); +}); + +test("the state filter hides the section it excludes", async () => { + const router = renderRoute(); + await screen.findByRole("heading", { name: "Diagnostics" }); + + await pick("Show", "Active only"); + + await waitFor(() => expect(router.state.location.search).toEqual({ state: "active" })); + expect(screen.queryByRole("heading", { name: "Resolved" })).toBeNull(); + expect(screen.getByRole("heading", { name: "Active" })).toBeTruthy(); +}); + +test("a url written by hand starts on the filters it names", async () => { + responses["/api/diagnostics?component=disk&state=resolved"] = RESOLVED; + renderRoute("/diagnostics?state=resolved&component=disk"); + await screen.findByRole("heading", { name: "Diagnostics" }); + + await screen.findByText("Disk space low"); + expect(screen.queryByRole("heading", { name: "Active" })).toBeNull(); + expect(requested).toContain("/api/diagnostics?component=disk&state=resolved"); +}); + +test("load more appends the next page of resolved history", async () => { + responses["/api/diagnostics?state=resolved"] = page( + [event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 })], + 30, + ); + responses["/api/diagnostics?state=resolved&before=30"] = page([ + event(12, { + code: "certificate.reload", + component: "certificate", + subject: "doh", + resolved_at: NOW_S - 90_000, + }), + ]); + renderRoute(); + await screen.findByText("Disk space low"); + + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + + await screen.findByText("TLS certificate reload failed"); + expect(screen.getByText(/Showing 2 resolved entries — end of history/)).toBeTruthy(); +}); + +test("an unavailable store reports the failure instead of loading forever", async () => { + responses["/api/diagnostics?state=active"] = fail(503, "store unavailable"); + renderRoute("/diagnostics", { retry: false }); + await screen.findByRole("heading", { name: "Diagnostics" }); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("The server is starting or degraded."); + expect(screen.queryByText("Loading diagnostics…")).toBeNull(); + // The resolved section answered, so it still renders its own history. + expect(screen.getByText("Disk space low")).toBeTruthy(); +}); + +test("a failed history query reports the failure and retries on demand", async () => { + responses["/api/diagnostics?state=resolved"] = fail(500, "diagnostics store read failed"); + renderRoute("/diagnostics", { retry: false }); + await screen.findByRole("heading", { name: "Diagnostics" }); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("diagnostics store read failed"); + expect(screen.queryByText("Loading history…")).toBeNull(); + + responses["/api/diagnostics?state=resolved"] = RESOLVED; + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + await screen.findByText("Disk space low"); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +test("only the resolved history offers a purge", async () => { + renderRoute(); + await screen.findByRole("heading", { name: "Diagnostics" }); + + // 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")!; + expect(within(active).queryByRole("button", { name: "Purge" })).toBeNull(); + + const row = screen.getByText("Disk space low").closest("tr")!; + expect(within(row).getByRole("button", { name: "Purge" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Purge all resolved" })).toBeTruthy(); +}); + +test("with no resolved history there is nothing to purge in bulk", async () => { + responses["/api/diagnostics?state=resolved"] = page([]); + renderRoute(); + await screen.findByRole("heading", { name: "Diagnostics" }); + + await screen.findByText("Nothing has failed and recovered in the retained window."); + expect(screen.queryByRole("button", { name: "Purge all resolved" })).toBeNull(); +}); + +test("purging one row asks first, then sends the DELETE and refetches the lists", async () => { + responses["DELETE /api/diagnostics/30"] = NO_CONTENT; + renderRoute(); + await screen.findByText("Disk space low"); + + fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" })); + + const dialog = await screen.findByRole("alertdialog"); + expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good."); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(requested).not.toContain("DELETE /api/diagnostics/30"); + + fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" })); + fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" })); + + await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/30")); + // The invalidation covers both sections: the page the row left and the + // active list, whose `active` counts come from the same table. + await waitFor(() => + expect(requested.filter((url) => url === "/api/diagnostics?state=resolved").length).toBeGreaterThan(1), + ); + await waitFor(() => + expect(requested.filter((url) => url === "/api/diagnostics?state=active").length).toBeGreaterThan(1), + ); +}); + +test("purging the whole history asks first and sends one DELETE", async () => { + responses["DELETE /api/diagnostics"] = { purged: 1 }; + renderRoute(); + await screen.findByText("Disk space low"); + + fireEvent.click(screen.getByRole("button", { name: "Purge all resolved" })); + const dialog = await screen.findByRole("alertdialog"); + expect(dialog.textContent).toContain("Purge all resolved events? Active events are kept."); + + // What the server will answer once the purge has landed; the refetch the + // mutation triggers is what has to pick it up. + responses["/api/diagnostics?state=resolved"] = page([]); + fireEvent.click(within(dialog).getByRole("button", { name: "Purge all" })); + + await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics")); + await waitFor(() => expect(screen.queryByText("Disk space low")).toBeNull()); + expect(screen.getByText("Blocklist source failed to update")).toBeTruthy(); +}); + +test("a refused purge reports the server's reason and keeps the row", async () => { + responses["DELETE /api/diagnostics/30"] = fail(409, "the event is still active; it can be purged once it resolves"); + renderRoute("/diagnostics", { retry: false }); + await screen.findByText("Disk space low"); + + fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" })); + fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" })); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("the event is still active"); + expect(screen.getByText("Disk space low")).toBeTruthy(); +}); + +test("an episode links to its own detail page", async () => { + responses["/api/diagnostics/42"] = event(42); + renderRoute(); + const link = await screen.findByRole("link", { name: "Blocklist source failed to update" }); + expect(link.getAttribute("href")).toBe("/diagnostics/42"); +}); diff --git a/admin/src/features/diagnostics/DiagnosticsPage.tsx b/admin/src/features/diagnostics/DiagnosticsPage.tsx new file mode 100644 index 0000000..b83ab7a --- /dev/null +++ b/admin/src/features/diagnostics/DiagnosticsPage.tsx @@ -0,0 +1,498 @@ +import { useState } from "react"; +import { + useInfiniteQuery, + useMutation, + useQueryClient, + type InfiniteData, + type UseInfiniteQueryResult, +} from "@tanstack/react-query"; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import * as stylex from "@stylexjs/stylex"; +import * as api from "@/lib/api"; +import InlineError from "@/lib/InlineError"; +import { formatDuration, formatTime } from "@/lib/format"; +import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries"; +import type { + DiagnosticEvent, + DiagnosticSeverity, + DiagnosticState, + DiagnosticsFilter, + DiagnosticsPage as Page, +} from "@/lib/types"; +import ConfirmDialog from "@/ui/ConfirmDialog"; +import Select from "@/ui/Select"; +import { styles as shared } from "@/ui/styles"; +import { colors } from "@/ui/tokens.stylex"; +import SeverityBadge from "./SeverityBadge"; +import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy"; + +const DARK = "@media (prefers-color-scheme: dark)"; + +const STATE_OPTIONS = [ + { value: "all", label: "Active and resolved" }, + { value: "active", label: "Active only" }, + { value: "resolved", label: "Resolved only" }, +]; + +const SEVERITY_OPTIONS = [ + { value: "any", label: "Any severity" }, + { value: "warning", label: "Warnings" }, + { value: "error", label: "Errors" }, +]; + +const COMPONENT_OPTIONS = [ + { value: "any", label: "All components" }, + ...DIAGNOSTIC_COMPONENTS.map((component) => ({ value: component, label: componentLabel(component) })), +]; + +const styles = stylex.create({ + heading: { + fontSize: "1.5rem", + lineHeight: "2rem", + fontWeight: 600, + }, + intro: { + marginTop: "0.25rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.textMuted, + maxWidth: "48rem", + }, + filterGrid: { + marginTop: "1rem", + display: "grid", + gap: "0.75rem", + gridTemplateColumns: { + default: "repeat(1, minmax(0, 1fr))", + "@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))", + }, + maxWidth: "48rem", + }, + sectionHeading: { + marginTop: "1.5rem", + fontSize: "1.125rem", + lineHeight: "1.75rem", + fontWeight: 600, + }, + sectionHeadingRow: { + display: "flex", + alignItems: "baseline", + flexWrap: "wrap", + justifyContent: "space-between", + gap: "0.75rem", + }, + /** + * Nothing open is the normal state of a working install, so it gets one + * quiet muted line — no border, no icon, no alert role. A panel here would + * read as a broken page rather than as good news. + */ + healthy: { + marginTop: "0.5rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.textSecondary, + }, + empty: { + marginTop: "0.5rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.textMuted, + }, + cardList: { + marginTop: "0.75rem", + display: "flex", + flexDirection: "column", + gap: "0.5rem", + listStyleType: "none", + padding: 0, + }, + card: { + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + backgroundColor: colors.surfaceRaised, + paddingInline: "0.75rem", + paddingBlock: "0.625rem", + }, + cardTop: { + display: "flex", + alignItems: "baseline", + flexWrap: "wrap", + gap: "0.5rem", + }, + cardTitle: { + fontWeight: 500, + color: colors.primaryOnSurface, + textDecorationLine: "none", + }, + subject: { + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.textSecondary, + wordBreak: "break-all", + }, + meta: { + marginTop: "0.25rem", + fontSize: "0.75rem", + lineHeight: "1rem", + color: colors.textMuted, + }, + tableWrap: { + marginTop: "0.75rem", + overflowX: "auto", + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + }, + table: { + width: "100%", + fontSize: "0.875rem", + lineHeight: "1.25rem", + }, + head: { + backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" }, + textAlign: "left", + }, + th: { + paddingInline: "0.75rem", + paddingBlock: "0.5rem", + fontWeight: 500, + color: colors.textSecondary, + whiteSpace: "nowrap", + }, + row: { + borderTopWidth: { default: 1, ":first-child": 0 }, + borderTopStyle: "solid", + borderTopColor: colors.border, + }, + cell: { + paddingInline: "0.75rem", + paddingBlock: "0.5rem", + }, + nowrap: { + whiteSpace: "nowrap", + }, + rowLink: { + color: colors.primaryOnSurface, + textDecorationLine: "none", + }, + footer: { + marginTop: "0.75rem", + display: "flex", + alignItems: "center", + gap: "0.75rem", + }, + note: { + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.textMuted, + }, + moreError: { + marginTop: "0.5rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.dangerText, + }, +}); + +type Section = UseInfiniteQueryResult, Error>; + +/** The two enum filters, narrowed from the picker's string rather than cast. */ +function asState(value: string): DiagnosticState | undefined { + return value === "active" || value === "resolved" ? value : undefined; +} + +function asSeverity(value: string): DiagnosticSeverity | undefined { + return value === "warning" || value === "error" ? value : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function occurrenceText(count: number): string { + return `${count} ${count === 1 ? "occurrence" : "occurrences"}`; +} + +function rowsOf(section: Section): DiagnosticEvent[] { + return (section.data?.pages ?? []).flatMap((page) => page.events); +} + +/** + * The cursor comes from the newest page on screen, not from `hasNextPage`: + * while placeholder data stands in for a filter change the query state is + * empty, and the button would flash away and back. + */ +function hasMore(section: Section): boolean { + const pages = section.data?.pages ?? []; + const last = pages[pages.length - 1]; + return last !== undefined && last.next_before !== null; +} + +function MoreButton({ section }: { section: Section }) { + const more = hasMore(section); + // A 401 is already redirecting via the cache-level handleUnauthorized. + const isUnauthorized = section.error instanceof api.ApiError && section.error.status === 401; + const failed = section.isFetchNextPageError && !isUnauthorized ? errorMessage(section.error) : null; + if (!more && failed === null) return null; + return ( + <> +
+ {more && ( + + )} +
+ {failed !== null && ( +

+ Failed to load more: {failed} +

+ )} + + ); +} + +function ActiveCard({ event, now }: { event: DiagnosticEvent; now: number }) { + const copy = copyFor(event.code); + return ( +
  • +
    + + + {copy.title} + + {event.subject} +
    +

    + Active for {formatDuration(now - event.first_seen)} · {occurrenceText(event.occurrences)} · last failure{" "} + {formatTime(event.last_seen)} +

    +
  • + ); +} + +function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge: () => void; busy: boolean }) { + const copy = copyFor(event.code); + return ( + + + + + + + {copy.title} + + + {event.subject} + {formatTime(event.first_seen)} + + {event.resolved_at === null ? "—" : formatTime(event.resolved_at)} + + {event.occurrences} + + + + + ); +} + +export default function DiagnosticsPage() { + const search = useSearch({ from: "/shell/diagnostics" }); + const navigate = useNavigate({ from: "/diagnostics" }); + const state = search.state ?? "all"; + + const base: DiagnosticsFilter = {}; + if (search.severity !== undefined) base.severity = search.severity; + if (search.component !== undefined) base.component = search.component; + + const active = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "active" }, state !== "resolved")); + const history = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "resolved" }, state !== "active")); + + const queryClient = useQueryClient(); + const purgeOne = useMutation(diagnosticPurgeMutation(queryClient)); + const purgeAll = useMutation(diagnosticsPurgeResolvedMutation(queryClient)); + // `null` is "no dialog"; the id is which row it is about, and `"all"` the + // whole history. One piece of state, so the two dialogs cannot both be open. + const [pendingPurge, setPendingPurge] = useState(null); + + const activeRows = rowsOf(active); + const historyRows = rowsOf(history); + const now = Math.floor(Date.now() / 1000); + const purging = purgeOne.isPending || purgeAll.isPending; + + function setSearch(patch: Partial) { + void navigate({ search: (prev) => ({ ...prev, ...patch }) }); + } + + function confirmPurge() { + if (pendingPurge === null) return; + if (pendingPurge === "all") { + purgeAll.mutate(); + } else { + purgeOne.mutate(pendingPurge); + } + setPendingPurge(null); + } + + return ( +
    +

    Diagnostics

    +

    + Operational failures, one entry per subject that failed. An entry opens on the first failure, counts + repeats, and closes when the subject recovers. +

    + +
    + setSearch({ severity: asSeverity(value) })} + options={SEVERITY_OPTIONS} + /> +