milestone 27: diagnostics — operational failures land in one curated log, resolved history purgeable
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s

This commit is contained in:
2026-08-20 20:05:59 +02:00
parent 3dd8214ef2
commit 037f209179
50 changed files with 8608 additions and 102 deletions
+18
View File
@@ -6,6 +6,10 @@ import type {
ClientEdit,
ClientPrefix,
ClientPrefixInput,
DiagnosticEvent,
DiagnosticsFilter,
DiagnosticsPage,
DiagnosticsPurge,
ForwardZone,
ForwardZoneInput,
Group,
@@ -121,6 +125,20 @@ export const getLookup = (domain: string, groupId?: number): Promise<LookupResul
export const getUpstreamHealth = (period?: Period): Promise<UpstreamHealth> =>
request(`/api/upstream/health${qs({ period })}`);
// Diagnostics
export const getDiagnostics = (filter: DiagnosticsFilter = {}): Promise<DiagnosticsPage> =>
request(`/api/diagnostics${qs({ ...filter })}`);
export const getDiagnostic = (id: number): Promise<DiagnosticEvent> => request(`/api/diagnostics/${id}`);
/** Purges one resolved event. An event still active answers 409, an unknown id 404. */
export const purgeDiagnostic = (id: number): Promise<void> => request(`/api/diagnostics/${id}`, { method: "DELETE" });
/** Purges the whole resolved history; active events are never touched. */
export const purgeResolvedDiagnostics = (): Promise<DiagnosticsPurge> =>
request("/api/diagnostics", { method: "DELETE" });
// Groups
export const listGroups = async (): Promise<Group[]> => (await request<{ groups: Group[] }>("/api/groups")).groups;
+59
View File
@@ -16,6 +16,9 @@ import type {
BlocklistEcho,
Client,
ClientPrefix,
DiagnosticEvent,
DiagnosticsPage,
DiagnosticsPurge,
ErrorEnvelope,
ForwardZone,
Group,
@@ -39,6 +42,11 @@ import type {
} from "@/lib/types";
export const sample_get_health: Health = {
diagnostics: {
active_errors: 0,
active_warnings: 0,
state: "recording",
},
disk: {
db_bytes: 0,
free_bytes: 0,
@@ -73,6 +81,57 @@ export const sample_logout: LogoutResponse = {
authenticated: false,
};
export const sample_get_diagnostics: DiagnosticsPage = {
active: {
errors: 0,
warnings: 0,
},
events: [
{
code: "upstream_history.write",
component: "upstream_history",
detail: "Busy",
first_seen: 0,
id: 0,
last_seen: 0,
occurrences: 0,
resolved_at: 0,
severity: "warning",
subject: "history",
},
{
code: "blocklist.refresh",
component: "blocklist",
detail: "download failed: ConnectionTimedOut",
first_seen: 0,
id: 0,
last_seen: 0,
occurrences: 0,
resolved_at: null,
severity: "warning",
subject: "StevenBlack",
},
],
next_before: null,
};
export const sample_get_diagnostic: DiagnosticEvent = {
code: "blocklist.refresh",
component: "blocklist",
detail: "download failed: ConnectionTimedOut",
first_seen: 0,
id: 0,
last_seen: 0,
occurrences: 0,
resolved_at: null,
severity: "warning",
subject: "StevenBlack",
};
export const sample_purge_diagnostics: DiagnosticsPurge = {
purged: 0,
};
export const sample_create_blocklist: BlocklistEcho = {
enabled: false,
id: 0,
+10 -1
View File
@@ -1,4 +1,4 @@
import { formatAge, formatBytes, formatMicros, formatTime } from "@/lib/format";
import { formatAge, formatBytes, 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.
@@ -27,6 +27,15 @@ test("formatAge steps up a unit at each boundary and truncates", () => {
expect(formatAge(400000)).toBe("4d ago");
});
test("formatDuration is the same span without the 'ago', and never negative", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(59)).toBe("59s");
expect(formatDuration(3600)).toBe("1h");
expect(formatDuration(86400)).toBe("1d");
// Clock skew between the server's timestamps and the browser's clock.
expect(formatDuration(-5)).toBe("0s");
});
test("formatMicros renders milliseconds with one decimal", () => {
expect(formatMicros(0)).toBe("0.0 ms");
expect(formatMicros(1234)).toBe("1.2 ms");
+12
View File
@@ -39,6 +39,18 @@ export function formatAge(seconds: number): string {
return `${Math.floor(seconds)}s ago`;
}
/**
* Seconds of elapsed time → a coarse "3h", the same single truncated unit as
* `formatAge` without the "ago". For a span the caller labels itself, as in
* "active for 3h". A negative span reads "0s": clock skew is not a duration.
*/
export function formatDuration(seconds: number): string {
for (const unit of AGE_UNITS) {
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix}`;
}
return `${Math.max(0, Math.floor(seconds))}s`;
}
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
export function formatMicros(micros: number): string {
return `${(micros / 1000).toFixed(1)} ms`;
+41
View File
@@ -5,6 +5,8 @@ import type {
BlocklistInput,
ClientEdit,
ClientPrefixInput,
DiagnosticsFilter,
DiagnosticsPage,
ForwardZoneInput,
GroupInput,
LocalRecordInput,
@@ -23,6 +25,10 @@ export const queryKeys = {
stats: (period: Period) => ["stats", period] as const,
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] 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. */
@@ -69,6 +75,28 @@ export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
placeholderData: keepPreviousData,
});
// Keyset pagination on `next_before`, exactly as the query log pages
// (handlers/diagnostics.zig copies the /api/queries contract). The active view
// polls on healthQuery's cadence because an episode opening is the same news a
// health banner carries; a resolved-history page is settled and does not poll.
// `enabled` belongs to the factory rather than to a spread at the call site:
// spreading the options object loses the page-param type, and the Diagnostics
// page turns one of its two sections off whenever a filter excludes it.
export const diagnosticsInfiniteQuery = (filter: DiagnosticsFilter = {}, enabled = true) =>
infiniteQueryOptions({
enabled,
queryKey: queryKeys.diagnosticsInfinite(filter),
queryFn: ({ pageParam }: { pageParam: number | undefined }) =>
api.getDiagnostics(pageParam === undefined ? filter : { ...filter, before: pageParam }),
initialPageParam: undefined as number | undefined,
getNextPageParam: (last: DiagnosticsPage) => last.next_before ?? undefined,
placeholderData: keepPreviousData,
refetchInterval: filter.state === "active" ? 10_000 : undefined,
});
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.
@@ -112,6 +140,19 @@ export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings,
// Group membership and names feed lookup verdicts and the group columns on
// clients, prefixes and rules, hence the wide invalidation on group mutations.
// Both purges invalidate the whole `diagnostics` prefix rather than one page
// key: the resolved list, the active list (whose `active` counts ride along) and
// the detail query of the row just deleted all describe the table that changed.
export const diagnosticPurgeMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.purgeDiagnostic(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.diagnosticsAll }),
});
export const diagnosticsPurgeResolvedMutation = (qc: QueryClient) => ({
mutationFn: () => api.purgeResolvedDiagnostics(),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.diagnosticsAll }),
});
function invalidateGroupWorld(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.groups }),
+81
View File
@@ -28,6 +28,16 @@ export interface Health {
writer_failed: boolean;
refreshes_gated: number;
snapshot_generation: 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
* counts below are the last ones it managed to observe.
*/
diagnostics: {
state: "recording" | "unavailable";
active_warnings: number;
active_errors: number;
};
}
export interface Version {
@@ -81,6 +91,77 @@ export interface QueriesFilter {
until?: number;
}
/**
* The fifteen operational event codes, in the order `src/storage/events.zig`
* declares them. A value, not only a type, because the copy map has to be
* proven exhaustive at runtime as well as by `tsc`.
*/
export const DIAGNOSTIC_CODES = [
"disk.space",
"disk.probe",
"blocklist.refresh",
"blocklist.snapshot",
"blocklist.storage",
"certificate.reload",
"query_log.write",
"query_log.maintenance",
"query_log.recreated",
"upstream_history.write",
"upstream.exchange",
"client_names.storage",
"clients.storage",
"listener.start",
"configuration.load",
] as const;
export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[number];
export type DiagnosticSeverity = "warning" | "error";
/** Which episodes a query selects. `all` is the server's default. */
export type DiagnosticState = "active" | "resolved" | "all";
export interface DiagnosticEvent {
id: number;
code: DiagnosticCode;
/** The part of `code` before the dot, repeated by the server for filtering. */
component: string;
/** The display identity of what failed; redacted where it derives from a url. */
subject: string;
severity: DiagnosticSeverity;
first_seen: number;
last_seen: number;
occurrences: number;
/** Null while the episode is still open. */
resolved_at: number | null;
detail: string;
}
export interface DiagnosticsPage {
events: DiagnosticEvent[];
next_before: number | null;
/** Episodes open right now, whatever this page filtered to. */
active: {
warnings: number;
errors: number;
};
}
/** `DELETE /api/diagnostics` — how many resolved events the purge removed. */
export interface DiagnosticsPurge {
purged: number;
}
export interface DiagnosticsFilter {
state?: DiagnosticState;
severity?: DiagnosticSeverity;
component?: string;
since?: number;
until?: number;
limit?: number;
before?: number;
}
export interface StatsTotals {
period: Period;
since: number;