milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s

This commit is contained in:
2026-08-22 16:45:15 +02:00
parent 17422fac21
commit 648d9b4496
89 changed files with 7222 additions and 4239 deletions
+12 -4
View File
@@ -33,11 +33,13 @@ import type {
SettingsEnvelope,
SettingsPatch,
SourceStatus,
StatsClients,
StatsRoutes,
StatsTimeseries,
StatsTotals,
StatsTypes,
Upstream,
UpstreamEcho,
UpstreamHealth,
UpstreamInput,
Version,
} from "@/lib/types";
@@ -122,13 +124,14 @@ export const liveQueriesUrl = "/api/queries/live";
export const getStats = (period?: Period): Promise<StatsTotals> => request(`/api/stats${qs({ period })}`);
export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
request(`/api/stats/timeseries${qs({ period })}`);
export const getStatsTypes = (period?: Period): Promise<StatsTypes> => request(`/api/stats/types${qs({ period })}`);
export const getStatsRoutes = (period?: Period): Promise<StatsRoutes> => request(`/api/stats/routes${qs({ period })}`);
export const getStatsClients = (period?: Period): Promise<StatsClients> =>
request(`/api/stats/clients${qs({ period })}`);
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
export const getUpstreamHealth = (period?: Period): Promise<UpstreamHealth> =>
request(`/api/upstream/health${qs({ period })}`);
// Diagnostics
export const getDiagnostics = (filter: DiagnosticsFilter = {}): Promise<DiagnosticsPage> =>
@@ -235,6 +238,11 @@ export const deleteUpstream = (id: number): Promise<void> => request(`/api/upstr
// Pause + settings
/**
* The pause state on its own. Protection is read from `/api/health` everywhere
* the interface shows it — one source, one story — so this is left for the live
* stream's session probe, which wants the cheapest authenticated GET there is.
*/
export const getPause = (): Promise<PauseState> => request("/api/pause");
export const postPause = (body: PausePost): Promise<PauseState> => request("/api/pause", { method: "POST", body });
+111 -33
View File
@@ -34,11 +34,13 @@ import type {
RuleEcho,
SettingsEnvelope,
SourceStatus,
StatsClients,
StatsRoutes,
StatsTimeseries,
StatsTotals,
StatsTypes,
Upstream,
UpstreamEcho,
UpstreamHealth,
Version,
} from "@/lib/types";
@@ -49,21 +51,24 @@ export const sample_get_health: Health = {
state: "recording",
},
disk: {
db_bytes: 0,
free_bytes: 0,
log_bytes: 0,
sample_failures: 0,
state: "ok",
},
queries_dropped: 0,
refreshes_gated: 0,
snapshot_generation: 0,
protection: {
state: "active",
until: null,
},
query_history: {
dropped_total: 0,
last_drop_s: null,
state: "recording",
},
status: "ok",
upstreams: {
available: 0,
state: "ok",
total: 0,
},
writer_failed: false,
};
export const sample_get_version: Version = {
@@ -391,30 +396,6 @@ export const sample_update_upstream: UpstreamEcho = {
url: "https://dns.example/dns-query",
};
export const sample_get_upstream_health: UpstreamHealth = {
available: 0,
complete: true,
period: "24h",
since: 0,
total: 0,
until: 0,
upstreams: [
{
available: true,
enabled: true,
period: {
attempts: 0,
failures: 0,
last_failure_at: null,
last_failure_error: null,
success_rate: null,
successes: 0,
},
url: "https://dns.example/dns-query",
},
],
};
export const sample_get_queries: QueriesPage = {
coverage: {
available_since: 0,
@@ -543,7 +524,6 @@ export const sample_get_query_detail: QueryDetail = {
export const sample_get_stats: StatsTotals = {
avg_response_time_us: null,
blocked: 0,
cached: 0,
clients: 0,
coverage: {
available_since: 0,
@@ -846,6 +826,104 @@ export const sample_error_not_found: ErrorEnvelope = {
error: "not found",
};
export const sample_get_stats_types: StatsTypes = {
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
since: 0,
types: [
{
count: 0,
qtype: 0,
},
{
count: 0,
qtype: null,
},
],
until: 0,
};
export const sample_get_stats_routes: StatsRoutes = {
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
routes: [
{
count: 0,
route: "upstream",
source: "https://dns.example/dns-query",
},
{
count: 0,
route: "blocked",
source: null,
},
{
count: 0,
route: "cache",
source: null,
},
{
count: 0,
route: "forward_zone",
source: "lan",
},
{
count: 0,
route: "local",
source: null,
},
{
count: 0,
route: "rejected",
source: null,
},
{
count: 0,
route: "upstream",
source: "https://dns2.example/dns-query",
},
{
count: 0,
route: "upstream",
source: null,
},
],
since: 0,
until: 0,
};
export const sample_get_stats_clients: StatsClients = {
bucket_seconds: 0,
clients: [
{
buckets: [0],
client: "192.0.2.30",
},
{
buckets: [0],
client: "192.0.2.31",
},
{
buckets: [0],
client: "192.0.2.32",
},
],
coverage: {
available_since: 0,
complete: true,
},
other: [0],
period: "1h",
since: 0,
until: 0,
};
export const sample_error_unauthorized: ErrorEnvelope = {
error: "authentication required",
};
+8 -1
View File
@@ -1,10 +1,17 @@
import { formatBytes, formatDuration, formatMicros, formatTime } from "@/lib/format";
import { formatBytes, formatClock, formatDuration, formatMicros, formatTime } from "@/lib/format";
test("formatTime renders unix seconds in the given locale and zone", () => {
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
expect(formatTime(1704067200, "en-US", "UTC").replace(//g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
});
test("formatClock states the time of day alone, for a stamp read against now", () => {
expect(formatClock(Date.UTC(2026, 0, 1, 14, 5) / 1000, "en-GB", "UTC")).toBe("14:05");
expect(formatClock(Date.UTC(2026, 0, 1, 9, 30) / 1000, "en-GB", "UTC")).toBe("09:30");
// No date: the caller places it against now, and a date would be noise.
expect(formatClock(Date.UTC(2026, 0, 1, 14, 5) / 1000, "en-GB", "UTC")).not.toMatch(/2026/);
});
test("formatBytes humanizes with binary units", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(1023)).toBe("1023 B");
+11
View File
@@ -7,6 +7,17 @@ export function formatTime(unixSeconds: number, locale?: string, timeZone?: stri
}).format(new Date(unixSeconds * 1000));
}
/**
* Unix seconds → the time of day alone, "14:05". For a stamp the reader places
* against now — a pause that ends shortly, the last row that was dropped —
* where the date would be noise on every reading but one.
*/
export function formatClock(unixSeconds: number, locale?: string, timeZone?: string): string {
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone }).format(
new Date(unixSeconds * 1000),
);
}
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
export function formatBytes(bytes: number): string {
+19
View File
@@ -0,0 +1,19 @@
/**
* A healthy `GET /api/health` body, for tests that need protection to be a
* settled fact rather than the subject under test. Overrides are per condition,
* so a test names only the one it is about.
*/
import type { Health } from "./types";
export function health(overrides: Partial<Health> = {}): Health {
return {
status: "ok",
protection: { state: "active", until: null },
upstreams: { state: "ok", available: 2, total: 2 },
query_history: { state: "recording", dropped_total: 0, last_drop_s: null },
diagnostics: { state: "recording", active_warnings: 0, active_errors: 0 },
disk: { state: "ok", free_bytes: 40 * 1024 * 1024 * 1024 },
...overrides,
};
}
+29 -17
View File
@@ -24,13 +24,15 @@ export const queryKeys = {
version: ["version"] as const,
stats: (period: Period) => ["stats", period] as const,
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
statsTypes: (period: Period) => ["stats", "types", period] as const,
statsRoutes: (period: Period) => ["stats", "routes", period] as const,
statsClients: (period: Period) => ["stats", "clients", period] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
queryDetail: (id: number) => ["queries", "detail", id] as const,
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
diagnostic: (id: number) => ["diagnostics", "event", id] as const,
/** Prefix of every diagnostics entry, page and detail alike; the purge target. */
diagnosticsAll: ["diagnostics"] as const,
upstreamHealth: (period: Period) => ["upstream-health", period] as const,
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
/** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */
lookupAll: ["lookup"] as const,
@@ -43,7 +45,6 @@ export const queryKeys = {
clients: ["clients"] as const,
clientPrefixes: ["client-prefixes"] as const,
upstreams: ["upstreams"] as const,
pause: ["pause"] as const,
settings: ["settings"] as const,
};
@@ -63,6 +64,27 @@ export const timeseriesQuery = (period: Period = "24h") =>
refetchInterval: 30_000,
});
export const statsTypesQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.statsTypes(period),
queryFn: () => api.getStatsTypes(period),
refetchInterval: 30_000,
});
export const statsRoutesQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.statsRoutes(period),
queryFn: () => api.getStatsRoutes(period),
refetchInterval: 30_000,
});
export const statsClientsQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.statsClients(period),
queryFn: () => api.getStatsClients(period),
refetchInterval: 30_000,
});
// Keyset pagination on `next_before` (handlers/queries.zig). A background
// refetch replays every page in cursor order, so newly logged rows shift the
// whole window instead of opening a gap between page 1 and page 2.
@@ -101,16 +123,6 @@ export const diagnosticsInfiniteQuery = (filter: DiagnosticsFilter = {}, enabled
export const diagnosticQuery = (id: number) =>
queryOptions({ queryKey: queryKeys.diagnostic(id), queryFn: () => api.getDiagnostic(id) });
// The period is part of the key: the upstream aggregates are ranged like the
// stats ones, so the picker has to refetch them rather than reuse a cached
// window under a new label.
export const upstreamHealthQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.upstreamHealth(period),
queryFn: () => api.getUpstreamHealth(period),
refetchInterval: 30_000,
});
export const lookupQuery = (domain: string, groupId?: number) =>
queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) });
@@ -136,8 +148,6 @@ export const clientPrefixesQuery = () =>
export const upstreamsQuery = () => queryOptions({ queryKey: queryKeys.upstreams, queryFn: api.listUpstreams });
export const pauseQuery = () => queryOptions({ queryKey: queryKeys.pause, queryFn: api.getPause });
export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings, queryFn: api.getSettings });
// Mutation option factories. Usage: useMutation(groupCreateMutation(useQueryClient())).
@@ -322,11 +332,13 @@ export const upstreamDeleteMutation = (qc: QueryClient) => ({
onSuccess: () => invalidateUpstreams(qc),
});
// Protection is a health condition, and health is the only thing that reads it:
// the sidebar control, the Diagnostics health strip and the related action on a
// blocked query all render `Health.protection`. Without this invalidation they
// would contradict a successful mutation until the next ten-second poll.
export const pauseMutation = (qc: QueryClient) => ({
mutationFn: (body: PausePost) => api.postPause(body),
onSuccess: (state: Awaited<ReturnType<typeof api.postPause>>) => {
qc.setQueryData(queryKeys.pause, state);
},
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.health }),
});
export const settingsPutMutation = (qc: QueryClient) => ({
+86 -47
View File
@@ -11,23 +11,39 @@ export interface ErrorEnvelope {
error: string;
}
/**
* The five conditions `GET /api/health` reports, and a `status` computed from
* exactly their states. Nothing degrades the rollup without appearing here, so
* a reader of this object can always name what degraded the box.
*/
export interface Health {
status: "ok" | "degraded";
disk: {
state: "ok" | "warn" | "critical";
free_bytes: number;
db_bytes: number;
log_bytes: number;
sample_failures: number;
/**
* Is filtering in force. `unavailable` is not an operator's doing: it is the
* state in which the query path has no filter snapshot to evaluate against.
* It outranks a pause, which is why `until` is null under it.
*/
protection: {
state: "active" | "paused" | "unavailable";
/** The second filtering resumes at; null for an indefinite pause and for every other state. */
until: number | null;
};
upstreams: {
state: "ok" | "unavailable";
available: number;
/** Enabled upstreams: the pool is built from those alone. */
total: number;
};
queries_dropped: number;
writer_failed: boolean;
refreshes_gated: number;
snapshot_generation: number | null;
/**
* Whether Activity can be trusted. `dropped_total` is cumulative and does not
* decide the state — a drop an hour ago is not a fault now.
*/
query_history: {
state: "recording" | "losing" | "failed";
dropped_total: number;
/** The newest drop; stamped by a separate atomic, so it can lag a non-zero count. */
last_drop_s: number | null;
};
/**
* The diagnostics store's own state, not a summary of what it holds:
* `unavailable` means the store is missing or its last write failed, so the
@@ -38,6 +54,11 @@ export interface Health {
active_warnings: number;
active_errors: number;
};
/** `low` is the monitor's `warn` renamed at the wire: warn reads as a log level. */
disk: {
state: "ok" | "low" | "critical";
free_bytes: number;
};
}
export interface Version {
@@ -276,7 +297,6 @@ export interface StatsTotals {
until: number;
queries: number;
blocked: number;
cached: number;
clients: number;
avg_response_time_us: number | null;
coverage: Coverage;
@@ -298,6 +318,61 @@ export interface StatsTimeseries {
coverage: Coverage;
}
/**
* One DNS type's share of the window. `qtype` is the numeric code as logged:
* naming it is the admin's job (`features/queries/qtype.ts`), and a row whose
* type was never recorded keeps its own `null` group rather than disappearing.
*/
export interface StatsTypeRow {
qtype: number | null;
count: number;
}
export interface StatsTypes {
period: Period;
since: number;
until: number;
types: StatsTypeRow[];
coverage: Coverage;
}
/**
* How the window's queries were answered. `source` names the answering upstream
* on `upstream` rows and the zone on `forward_zone` rows; every other route kind
* carries null, as does a row whose identity was not recorded.
*/
export interface StatsRouteRow {
route: RouteKind;
source: string | null;
count: number;
}
export interface StatsRoutes {
period: Period;
since: number;
until: number;
routes: StatsRouteRow[];
coverage: Coverage;
}
/** One client's per-bucket counts, aligned to `StatsTimeseries`'s buckets. */
export interface StatsClientSeries {
client: string;
buckets: number[];
}
export interface StatsClients {
period: Period;
since: number;
until: number;
bucket_seconds: number;
/** The eight busiest clients in the window, ranked by total count. */
clients: StatsClientSeries[];
/** Everything outside the top eight. Always present and always bucket-count-sized. */
other: number[];
coverage: Coverage;
}
export interface LookupResult {
domain: string;
group_id: number;
@@ -310,42 +385,6 @@ export interface LookupResult {
safe_search_rewrite: string | null;
}
export interface UpstreamPeriodStats {
attempts: number;
successes: number;
failures: number;
/** successes/attempts, 0 to 1; null when attempts is 0 — no observations is not perfect reliability. */
success_rate: number | null;
/** The newest failure inside the window, unix seconds; null when the window holds none. */
last_failure_at: number | null;
/** The error name belonging to last_failure_at; null exactly when it is. */
last_failure_error: string | null;
}
export interface UpstreamHealthEntry {
url: string;
/** Live configuration, not history. */
enabled: boolean;
/** Live state; false while the upstream is backing off. */
available: boolean;
period: UpstreamPeriodStats;
}
export interface UpstreamHealth {
period: Period;
since: number;
until: number;
available: number;
total: number;
/**
* No capacity drops known in this process within the selected window; up to about a minute of
* the newest outcomes may not have flushed yet, and outcomes lost in an unclean shutdown are
* not detectable.
*/
complete: boolean;
upstreams: UpstreamHealthEntry[];
}
export interface Group {
id: number;
name: string;