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
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user