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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user