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
+10 -1
View File
@@ -13,15 +13,24 @@ Query provenance: every logged query becomes exactly explainable — what the po
- **Every logged query has a detail page.** A row in Activity now links to `/activity/queries/{id}`, which explains that one query in the order it was decided: the request, the group it was matched under, the policy verdict with the rule that produced it and the blocklist source that rule came from, any CNAME uncloaking or safe-search rewrite, the route the answer took — blocked, local, forward zone, upstream or cache — and what the client got back, RCODE and duration included. `GET /api/queries/{id}` serves the same object; an id that retention has already deleted is a 404. The live view carries the same provenance for the queries it streams, so a query is explainable as it happens as well as afterwards.
- **Query Log, Live and Lookup are one Activity page.** `/activity` is the single surface for what nxdns answered: History reads the stored log, Live reads the stream, and both show the same seven columns — Time, Domain, Client, Type, Result, Route, Duration. The mode and every filter live in the URL, so an investigation is one link that shows the recipient exactly what you were looking at, and an absolute time range stays that range instead of drifting as the day goes on. A new **Result** column says what the client actually got — `Blocked`, `NOERROR`, `SERVFAIL` and the rest — with the **Route** column beside it saying how the answer was produced, which is the pair the old Status column could not show: a blocked name is answered with NOERROR, and reading only the code made a block look like a success. Both unhappy cases are marked by weight and shape as well as colour. Switching between History and Live keeps your filters, and leaving Live closes the stream instead of holding a viewer slot open. A live row that the log has not written yet opens its own provenance in place — no invented row id — and the open detail stays put while the 500-row buffer scrolls past underneath it. Domain testing moves to `/activity/test` as **Current policy simulation**, worded so it can never be misread as an account of a query that already happened.
- **Diagnostics can be scoped to an absolute window.** `/diagnostics?since=…&until=…` now validates and applies both bounds to the active and resolved lists, and the page states the window it is showing with a way to clear it. A query's detail page links here with the five minutes either side of that query, which is where the underlying failure text for a SERVFAIL lives.
- **The query log and the dashboard say how far back the history goes.** `GET /api/queries`, `/api/stats` and `/api/stats/timeseries` each carry a `coverage` object: `available_since`, the first second the file can answer for, and `complete`, whether the window you asked for begins inside it. A period that starts before the query log does now says so instead of charting the missing part as zero — which is what a recreate, a retention pass or a fresh install would otherwise look like.
- **The query log and the stats endpoints say how far back the history goes.** `GET /api/queries` and all five `/api/stats*` endpoints each carry a `coverage` object: `available_since`, the first second the file can answer for, and `complete`, whether the window you asked for begins inside it. A period that starts before the query log does now says so instead of charting the missing part as zero — which is what a recreate, a retention pass or a fresh install would otherwise look like.
- **Three new period breakdowns: `GET /api/stats/types`, `/api/stats/routes` and `/api/stats/clients`.** They take the same `period` parameter as `/api/stats` and report over the same UTC-aligned window, so every panel of one page describes the same span. `types` counts queries per DNS type, with the queries that recorded no type kept as their own row instead of dropped — the numeric type only, since naming types is the admin's job and a second table in the server would drift out of agreement with it. `routes` counts queries by how they were answered, grouping upstream rows by the answering resolver and forward-zone rows by the zone, with blocked, cache, local and rejected answers carrying no source. `clients` returns one bucketed series per client, aligned exactly like `/api/stats/timeseries` so the two charts share an x-axis: the eight busiest clients in the window are named and everything else sums into an `other` series, which is always present and always the same length as the named ones.
- **Each of those responses is read atomically.** Every window-bounded read — the five stats endpoints and `GET /api/queries` — now takes its rows and its coverage watermark inside one SQLite read transaction. A retention pass that runs mid-response can no longer hand back rows from before the prune tagged with an `available_since` from after it, and the clients breakdown ranks and buckets from one database state rather than two. The transaction is a deferred read, so it never blocks the query logger or retention.
### Removed
- **The upstream-history subsystem and `GET /api/upstream/health` are gone.** nxdns recorded every upstream exchange into per-minute aggregates in `querylog.db` so the dashboard could show each upstream's counts, success rate and last failure over the selected period. The Overview replacement drops that table, which left a writer whose only reader was its own failure signal, so the whole subsystem goes: the accumulator and its flush task, the `upstream_targets` and `upstream_minute` tables, the `/api/upstream/health` endpoint, the retention pass over the minute rows, and the four `nxdns_upstream_history_*` and one `nxdns_retention_upstream_rows_pruned_total` Prometheus metrics. What replaces it: `/api/health` says how many upstreams are available of how many enabled, `/metrics` keeps the live per-upstream `nxdns_upstream_up` and `nxdns_upstream_success_rate` series, and a failing upstream is a Diagnostics episode (`upstream.exchange`) with its own error text and duration. Ranged per-upstream counts are not replaced. Existing `upstream_history.write` diagnostics entries stay readable; nothing writes new ones, and any that were still open when you upgrade are closed at the first start.
- **`/queries`, `/queries/{id}`, `/live` and `/lookup` are gone, and bookmarks to them break.** There is no redirect and no alias: the paths simply stop resolving, and the app shows its not-found page. Everything those pages did is on `/activity`, `/activity/queries/{id}` and `/activity/test`. Three navigation entries collapse into one, "Activity". The API is untouched — `/api/queries`, `/api/queries/{id}`, `/api/queries/live` and `/api/lookup` all answer exactly as before.
- **The Status column, and the block reason on every row.** The reason a query was blocked was repeated on each of a hundred rows and pushed the answer the client saw off the table. Result and Route replace it; the exact rule, the blocklist source and the historical group stay one click away on the query's detail page, which is the only place they were ever readable.
### Changed
- **The Dashboard is now Overview, and it takes Pi-hole's layout.** `/` redirects to `/overview`, and the page answers one question — what the resolver did over a period you choose — instead of laying out six widgets. The 1h/24h/7d/30d period is URL state (`/overview?period=1h`), so the view you are reading is a link you can send. Top to bottom: four neutral stat tiles — queries, blocked with its share, distinct clients, average response time — each linking into the rows behind its number; the query-volume timeline split blocked, cached and other; a new per-client chart on the same axis, the busiest clients named — by their registered or reverse-DNS name where they have one, exactly as the query tables name them — and the rest summed as "other"; and two donuts, query types and how queries were answered, with each upstream and forward zone named separately. Colours follow the identity of a client, a type or a route rather than its rank, so one client overtaking another between refreshes does not repaint the page. Each donut is drawn as decoration with a visible legend beside it and a table a screen reader reads instead of the graphic. All five panels describe one window — matched on the period, both bounds and the coverage watermark together — so a refresh that straddles a bucket boundary, or a retention pass mid-page, can never put a headline count above a chart of a different span. Panels load, fail and retry on their own: a failing donut leaves the charts standing. A period with nothing in it says "No queries in this period." rather than drawing an empty frame.
- **The five health conditions moved to Diagnostics, and the nav item says when to look.** Protection, Upstreams, Query history, Diagnostics and Storage are now a compact strip at the top of `/diagnostics`, above the episodes that explain them, instead of a status list on the landing page. Each states its state in words and an icon as well as colour. A healthy condition is quiet; a degraded one is highlighted and links to what can fix it: protection to Blocklists, no reachable upstream to Upstreams, and a losing or failed query log or a low or critical disk to this same page filtered to the component that failed, with any time window cleared so the filter cannot hide the episodes it points at. Dropped rows are reported with the time of the newest drop, so a loss stays visible after the box recovers. When a health poll fails, the conditions on screen are labelled as the last reading that arrived rather than passing for the current state, and a Retry sits beside them. The Diagnostics navigation item carries a badge with the number of open episodes; it shows a plain "!" when the rollup is degraded with nothing open, and also when the last health poll failed, because an unknown must not look like good news. The badge is absent only when health answered and there was nothing to report.
- **Pause moved to the sidebar, and the header indicator is gone.** The header carries nothing but the menu button and Log out. Pause and Resume sit at the foot of the navigation sidebar, above the version label, in both the desktop rail and the phone drawer — one global runtime action in the one place that belongs to the resolver rather than to whichever page you are on. The control still appears beside the detail of a query that was blocked, which is the other place the action answers what you are looking at. The control says what it is doing as well as what it offers: Pause while filtering is on, and while it is off, "Paused until 14:05" — or plain "Paused" when the pause has no end — above the Resume button, on every page. "Resume" on its own would name an action without naming the state it ends, and with the header indicator gone no other page could tell you filtering was off. Both controls read the same `protection` condition, so they cannot disagree, and a pause or resume is reflected immediately rather than at the next poll. Nothing offers to pause while protection is unavailable, since pausing a resolver with no filter snapshot changes nothing.
- **`GET /api/health` changed shape completely.** The body is now `status` plus five condition objects — `protection`, `upstreams`, `query_history`, `diagnostics` and `disk` — and `status` is `degraded` when, and only when, one of them is in a degrading state: protection `unavailable`, upstreams `unavailable`, query history `losing` or `failed`, diagnostics `unavailable`, or disk `low` or `critical`. Nothing can degrade the rollup without appearing in the response any more; the old hidden upstream-history contribution was the reason for the rewrite. A paused protection is reported and does not degrade, because it is a choice you made rather than a fault. `queries_dropped` and `writer_failed` fold into `query_history`, which also carries `last_drop_s`, the time of the newest dropped row; `refreshes_gated` and `snapshot_generation` leave the body and stay in `/metrics`, as do the disk `db_bytes`, `log_bytes` and `sample_failures` fields. The disk monitor's `warn` state is reported as `low`, because `warn` reads as a log level rather than as a quantity of disk. This is a breaking change to a documented endpoint, taken pre-v0.1 rather than carried.
- **Upgrading resets your query history a second time.** Dropping the `upstream_targets` and `upstream_minute` tables changes the `querylog.db` schema fingerprint, and that file is never migrated, so the first start after this release sets the old one aside as `querylog.db.schema-changed-<unix seconds>` and creates a fresh one — exactly as the provenance change above does, and in the same start. `config.db` is untouched.
- **`GET /api/stats` no longer reports `cached`.** The standalone cache card is gone from Overview, so the totals field behind it has no consumer. Cache hits stay visible in the query-volume timeline's blocked/cached/other split, in `GET /api/stats/timeseries`, and in `nxdns_cache_hits_total`.
- **`GET /api/queries` rows changed shape.** Each row gains `qclass`, `rcode`, `policy_action`, `policy_reason` and `route_kind`, and `block_reason` is gone: the reason a query was blocked is now one of a closed set of values rather than a formatted string. No table column shows it — the reason is read on the query's detail page, and by an API client from `policy_reason` on the row. `blocked`, `cache_hit`, `upstream` and every other existing field are unchanged.
- **Upgrading resets your query history.** The `query_log` table gains the provenance columns below, and `querylog.db` is never migrated (it holds expendable log rows, so a schema change replaces the file instead of upgrading it). On the first start after the upgrade the old file is set aside as `querylog.db.schema-changed-<unix seconds>` and a fresh one is created. Nothing else is touched: `config.db` keeps your configuration and your diagnostics history. The recreate files a resolved `query_log.recreated` diagnostics entry naming the file that was kept and the timestamp the new history begins at, and a new `querylog_meta` table records that coverage start, so the dashboard can say "history is available from ..." instead of charting an empty range as zero. The set-aside file is a working SQLite database and can be deleted once you have decided you do not want it.
- **`logging.query_log_buffer_max` now accepts 1 to 37449, down from 1 to 1000000.** The queued entry carries every new provenance field by value and is about four times as wide as before — 1792 bytes against 432 — so the meaningful bound is bytes rather than entries. The ceiling is computed at compile time from the width of the entry so that the queue's worst case stays within 64 MiB, and it moves whenever that width does. The default of 10000 is unchanged and costs about 17 MiB. A configuration above the new ceiling is rejected at startup with the ceiling in the message.
+7 -26
View File
@@ -25,9 +25,9 @@ Serves a household LAN (≈220 devices). Portfolio-grade public repo with ext
- Domain filtering: blocklists (hosts/domains/ABP, including `@@||name^` exception lines), custom rules (allow/block; exact, parent-walk, wildcard, regex), CNAME uncloaking (depth 8), per-group safe-search rewrite.
- DNS caching: positive + negative, in-memory only.
- Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments.
- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, dashboard + time buckets, live SSE stream.
- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, Overview + time buckets, live SSE stream.
- Web app + REST API: LAN/Tailscale admin UI, optional password auth, OpenAPI schema + CI contract tests.
- Observability: upstream health API + UI, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
- Observability: `/api/health` conditions surfaced as a Diagnostics badge and health strip, live upstream availability in `/metrics`, a failing upstream as a Diagnostics episode, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
- Ops: config authority chosen by the invocation (database, or a file named by `--config`), `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose.
### 2.2 Out of Scope (permanent scope decisions, not deferrals)
@@ -325,7 +325,7 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
- Schemes: `https://…` → DoH, `tls://host:853` → DoT.
- Ordered by priority; sequential attempt; per-upstream failure counters; exponential backoff with jitter; success resets.
- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. This is routing state: it drives failover and backoff, and is exposed through `/metrics` and `nxdns check`. `GET /api/upstream/health?period=…` exposes none of it except the live `enabled`/`available` pair; its counts, success rate and last failure are ranged aggregates read from the per-minute upstream history in `querylog.db`, so the dashboard's period scopes them like every other number on the page.
- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. This is routing state: it drives failover and backoff, and is exposed through `/metrics` and `nxdns check`. There is no per-upstream API surface: `/api/health` reports the pool as available-of-enabled, and a failing upstream is a Diagnostics episode (`upstream.exchange`).
- DoH client: `std.http.Client` with `content-type/accept: application/dns-message`; strict status + payload checks.
- `platform/tls_client.zig` enforces per-connection read/write deadlines, classifies TLS errors explicitly, retries with backoff. Integration tests cover timeout/hang scenarios so compiler upgrades can't silently regress them.
- Connect, read, and total-budget timeouts each configurable.
@@ -480,24 +480,6 @@ CREATE TABLE query_log (
CREATE INDEX idx_query_log_ts ON query_log(timestamp);
CREATE INDEX idx_query_log_client ON query_log(client_ip);
CREATE INDEX idx_query_log_domain ON query_log(domain_id);
CREATE TABLE upstream_targets (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE -- the historical identity: config.db ids cannot cross database files
);
CREATE TABLE upstream_minute (
upstream_id INTEGER NOT NULL REFERENCES upstream_targets(id),
minute_ts INTEGER NOT NULL,
successes INTEGER NOT NULL,
failures INTEGER NOT NULL,
last_failure_ts INTEGER,
last_error TEXT,
PRIMARY KEY (upstream_id, minute_ts),
CHECK (successes >= 0),
CHECK (failures >= 0)
) WITHOUT ROWID;
CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
```
### 11.4 Query Logger
@@ -552,7 +534,7 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
### 13.1 Endpoints
- `POST /api/auth/login`, `POST /api/auth/logout`
- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…`
- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…`, `GET /api/stats/types?period=…`, `GET /api/stats/routes?period=…`, `GET /api/stats/clients?period=…`
- `GET /api/queries` (filter + paginate), `GET /api/queries/live` (SSE, per-IP cap)
- `GET/PUT /api/clients/{id}`
- `GET/POST/PUT/DELETE /api/groups…`, `/api/blocklists…`, `/api/rules…`, `/api/local-records…`, `/api/forward-zones…`
@@ -560,9 +542,8 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
- `GET /api/lookup?domain=…&group_id=…`
- `GET/POST /api/pause`
- `GET/PUT /api/settings`
- `GET /api/upstream/health?period=…`
- `POST /api/certs/reload`
- `GET /api/health`overall + disk + upstream + queries_dropped rollup
- `GET /api/health`five condition objects (protection, upstreams, query history, diagnostics, disk) and the status computed from exactly their states
- `GET /metrics` — Prometheus text exposition: query counters (total/blocked/cached), per-upstream health, cache stats, queries_dropped, disk gauges
- `GET /api/version`, `GET /api/openapi.yaml`
@@ -574,7 +555,7 @@ Hand-maintained `openapi.yaml`, served at `GET /api/openapi.yaml` and mirrored b
## 14. Frontend
Pages: Dashboard (stats + upstream health + disk), Query log, Live log, Clients, Groups, Blocklists, Rules, Local DNS (records + forward zones), Domain lookup, Settings.
Pages: Overview (stat tiles, queries over time, client activity over time, and query-type and upstream breakdowns, all over one period), Query log, Live log, Clients, Groups, Blocklists, Rules, Local DNS (records + forward zones), Domain lookup, Settings.
Requirements: responsive desktop/mobile; route loaders for initial fetch; TanStack Query for cache/retries; error/loading states on every data view; works with auth enabled or disabled; restart-required banner.
@@ -632,7 +613,7 @@ systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/v
- **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON loading + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade).
- **Fuzz**: DNS parser malformed-packet fuzzing; blocklist parser fuzzing.
- **Integration**: UDP/TCP query path; blocked path; allow-over-block; wildcard precedence; CNAME uncloaking block; local records + forward zones; upstream failover/backoff/health; disk-full degradation; querylog.db corruption recovery; API CRUD; auth on/off; SSE; contract tests.
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log.
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; Overview + live log.
---
@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, 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";
@@ -6,6 +6,7 @@ import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { QueryDetail } from "@/lib/types";
import { provenance } from "@/features/queries/provenanceFixture";
import { health } from "@/lib/healthFixture";
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
return { id, ...provenance(sections) };
@@ -16,6 +17,7 @@ let responses: Record<string, unknown>;
beforeEach(() => {
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
"/api/health": health(),
};
vi.stubGlobal(
"fetch",
@@ -310,3 +312,43 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
domain: "gone",
});
});
/**
* The related-actions region of a query detail. Scoped on purpose: the sidebar
* carries a Pause of its own, and this is the one that answers "this query was
* blocked and should not have been".
*/
function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" });
}
test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => {
responses["/api/queries/50"] = detail(50, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" },
});
renderDetail(50);
await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
cleanup();
responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } });
renderDetail(51);
await screen.findByRole("heading", { name: "example.com" });
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
});
test("the Pause action stays away while protection is unavailable", async () => {
responses["/api/health"] = health({ protection: { state: "unavailable", until: null } });
responses["/api/queries/52"] = detail(52, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" },
});
renderDetail(52);
await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(screen.getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
});
@@ -68,7 +68,15 @@ export default function ActivityDetailPage() {
<ProvenanceDetail
provenance={detail}
persistedId={detail.id}
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
relatedActions={
<RelatedActions
domain={domain}
client={client}
ts={time}
origin={origin}
blocked={detail.policy.action === "block"}
/>
}
/>
</section>
);
@@ -9,6 +9,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/queries/provenanceFixture";
@@ -91,6 +92,8 @@ function stubFetch(handler: (url: string) => Response | Promise<Response>) {
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
@@ -6,7 +6,7 @@
* which subtree the URL mounts, not by a prop a caller could pass.
*/
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { act, 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";
@@ -14,6 +14,7 @@ import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { Client } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { health } from "@/lib/healthFixture";
import { FakeEventSource } from "./fakeEventSource";
function client(ip: string, name: string, learnedName: string): Client {
@@ -50,6 +51,8 @@ function stubFetch(handler: (url: string) => Response | Promise<Response> = () =
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
if (url === "/api/clients") return Promise.resolve(json({ clients: CLIENTS }));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
@@ -180,6 +183,8 @@ test("rows stream in as bare IPs while the client list is still loading", async
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return new Promise<Response>((resolve) => {
if (url !== "/api/clients") {
resolve(json({}));
@@ -397,3 +402,37 @@ test("leaving live closes the stream, and coming back opens exactly one fresh on
expect(sources).toHaveLength(2);
expect(sources[1]!.closed).toBe(false);
});
/**
* The related-actions region of a query detail. Scoped on purpose: the sidebar
* carries a Pause of its own, and this is the one that answers "this query was
* blocked and should not have been".
*/
function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" });
}
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => {
await openLive();
act(() =>
sources[0]!.emit(
"query",
frame(1000, "streamed.example", {
policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" },
route: { kind: "blocked", upstream: "" },
}),
),
);
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
});
test("a streamed row that was allowed offers nothing to pause", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1001, "allowed.example", { policy: { action: "allow" } })));
fireEvent.click(screen.getByRole("button", { name: "allowed.example" }));
await screen.findByRole("heading", { level: 1, name: "allowed.example" });
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
});
@@ -268,6 +268,7 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
client={summary.client_ip}
ts={summary.ts}
origin={origin}
blocked={row.event.policy.action === "block"}
/>
}
/>
@@ -274,8 +274,13 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
</Section>
</div>
<div {...stylex.props(styles.related)}>
<h2 {...stylex.props(styles.relatedHeading)}>Related</h2>
{/* A named region, because the Pause it may offer is not the only Pause
on screen: the sidebar carries one too, and the two answer different
questions. */}
<section aria-labelledby="related-actions" {...stylex.props(styles.related)}>
<h2 id="related-actions" {...stylex.props(styles.relatedHeading)}>
Related
</h2>
<p {...stylex.props(styles.relatedNote)}>
These read the current configuration, which may no longer be the one that decided this query.
</p>
@@ -294,7 +299,7 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
</p>
)}
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
</div>
</section>
</>
);
}
@@ -11,6 +11,7 @@
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import PauseControl from "@/features/pause/PauseControl";
import { styles as shared } from "@/ui/styles";
import { provenanceRelatedLink } from "./ProvenanceDetail";
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
@@ -23,9 +24,15 @@ interface Props {
ts: number;
/** The Activity search the reader came from; its bounds win over the defaults. */
origin: Pick<ActivitySearch, "since" | "until">;
/**
* This query was blocked. Pausing is a valid answer to a block the reader
* disagrees with, and to nothing else here — so the control appears for a
* block and not beside an allowed query it could not have caused.
*/
blocked: boolean;
}
export default function RelatedActions({ domain, client, ts, origin }: Props) {
export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) {
const bounds = relatedBounds(ts, origin);
const window = diagnosticsBounds(ts);
return (
@@ -54,6 +61,7 @@ export default function RelatedActions({ domain, client, ts, origin }: Props) {
>
Diagnostics around this query
</Link>
{blocked && <PauseControl />}
</>
);
}
@@ -1,252 +0,0 @@
import { fireEvent, render, screen } 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";
/** Wall clock at import; the upstream fixtures date their failures against it. */
const NOW_S = Math.floor(Date.now() / 1000);
const RESPONSES: Record<string, unknown> = {
"/api/stats?period=24h": {
period: "24h",
since: 0,
until: 86400,
queries: 1000,
blocked: 250,
cached: 100,
clients: 7,
avg_response_time_us: 2345,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=24h": {
period: "24h",
since: 0,
until: 86400,
bucket_seconds: 1800,
buckets: [
{ ts: 0, queries: 60, blocked: 20, cached: 10 },
{ ts: 1800, queries: 40, blocked: 0, cached: 0 },
{ ts: 3600, queries: 0, blocked: 0, cached: 0 },
],
coverage: { complete: true, available_since: 0 },
},
"/api/stats?period=1h": {
period: "1h",
since: 0,
until: 3600,
queries: 12,
blocked: 3,
cached: 0,
clients: 2,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=1h": {
period: "1h",
since: 0,
until: 3600,
bucket_seconds: 60,
buckets: [],
coverage: { complete: true, available_since: 0 },
},
"/api/health": {
status: "degraded",
disk: {
state: "warn",
free_bytes: 400 * 1024 * 1024,
db_bytes: 12 * 1024 * 1024,
log_bytes: 2048,
sample_failures: 0,
},
upstreams: { available: 1, total: 2 },
queries_dropped: 5,
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",
since: NOW_S - 86_400,
until: NOW_S,
available: 1,
total: 2,
complete: true,
upstreams: [
{
url: "https://dns.example/dns-query",
enabled: true,
available: false,
period: {
attempts: 100,
successes: 90,
failures: 10,
success_rate: 0.9,
// 3h30m before the fixture's now, far from a unit boundary.
last_failure_at: NOW_S - 12_600,
last_failure_error: "timeout",
},
},
{
url: "udp://9.9.9.9:53",
enabled: true,
available: true,
period: {
attempts: 100,
successes: 100,
failures: 0,
success_rate: 1,
last_failure_at: null,
last_failure_error: null,
},
},
],
},
"/api/upstream/health?period=1h": {
period: "1h",
since: NOW_S - 3600,
until: NOW_S,
available: 1,
total: 1,
complete: true,
upstreams: [
{
url: "https://dns.example/dns-query",
enabled: true,
available: true,
period: {
attempts: 7,
successes: 6,
failures: 1,
success_rate: 6 / 7,
last_failure_at: NOW_S - 300,
last_failure_error: "timeout",
},
},
],
},
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
// Endpoints forced to fail with a 4xx, which the query client does not retry.
let failing: Set<string>;
beforeEach(() => {
failing = new Set();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (failing.has(url)) {
return new Response(JSON.stringify({ error: "upstream health unavailable" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderDashboard() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
}
test("dashboard renders stats, chart, disk card, upstream table and health banners", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByText("250")).toBeTruthy();
expect(screen.getByText("25.0%")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("2.3 ms")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.getByText("warn")).toBeTruthy();
expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0);
expect(screen.getByText("12.0 MiB")).toBeTruthy();
expect(screen.getByText("2.0 KiB")).toBeTruthy();
const alerts = screen.getAllByRole("alert");
expect(alerts.some((alert) => /disk space low/i.test(alert.textContent ?? ""))).toBe(true);
expect(alerts.some((alert) => /5 queries dropped/i.test(alert.textContent ?? ""))).toBe(true);
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
expect(screen.getByText("90.0%")).toBeTruthy();
expect(screen.getByText("100.0%")).toBeTruthy();
expect(screen.getByText("1/2 available")).toBeTruthy();
});
test("live state is labeled on its own card, not by a section that disowns the picker", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.queryByRole("region", { name: "Right now" })).toBeNull();
expect(screen.queryByText("Right now")).toBeNull();
expect(screen.queryByText("Snapshot state; the period above does not apply.")).toBeNull();
});
test("the period picker rescopes the upstream table", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
await screen.findByText("90.0%");
fireEvent.click(screen.getByRole("button", { name: "1h" }));
await screen.findByText("85.7%");
expect(screen.getByRole("columnheader", { name: "Selected period · 1h" })).toBeTruthy();
expect(screen.queryByText("90.0%")).toBeNull();
});
test("period picker refetches stats and shows the empty chart state", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
fireEvent.click(screen.getByRole("button", { name: "1h" }));
await screen.findByText("12");
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
await screen.findByText("No queries in this period.");
expect(screen.getByText("—", { selector: "span" })).toBeTruthy();
});
test("one failing endpoint degrades its own widget on cold navigation", async () => {
failing.add("/api/upstream/health?period=24h");
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
// The page renders; only the upstream widget carries the error.
await screen.findByText("upstream health unavailable");
expect(screen.queryByText("Something went wrong")).toBeNull();
expect(screen.queryByText("Request failed (400)")).toBeNull();
expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
});
@@ -1,173 +0,0 @@
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards";
import TimeseriesChart from "./TimeseriesChart";
import UpstreamHealthTable from "./UpstreamHealthTable";
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
titleRow: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
period: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
/** Dynamic: the caller sizes the placeholder to the widget it stands in for. */
skeletonHeight: (height: number) => ({ height }),
skeleton: {
borderRadius: "0.25rem",
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
},
},
/** The chart takes two thirds beside the storage card from `lg`, one column below. */
panelGrid: {
display: "grid",
gap: "1rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 1024px)": "2fr 1fr",
},
},
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
});
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
{...stylex.props(
styles.period,
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
>
{option}
</button>
))}
</div>
);
}
function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
}
export default function DashboardPage() {
const [period, setPeriod] = useState<Period>("24h");
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
const health = useQuery(healthQuery());
const upstreamHealth = useQuery({ ...upstreamHealthQuery(period), placeholderData: keepPreviousData });
return (
<section {...stylex.props(styles.page)}>
<div {...stylex.props(styles.titleRow)}>
<h1 {...stylex.props(styles.heading)}>Dashboard</h1>
<PeriodPicker period={period} onChange={setPeriod} />
</div>
{health.data !== undefined && <HealthBanners health={health.data} />}
{stats.isError ? (
<InlineError error={stats.error} onRetry={() => void stats.refetch()} />
) : stats.data === undefined ? (
<Skeleton height={76} />
) : (
<StatCards stats={stats.data} />
)}
{/* One notice for the period: the chart is judged against the same
aligned lower bound as the totals, so it would say the same thing. */}
{stats.data !== undefined && <CoverageNotice coverage={stats.data.coverage} />}
<div {...stylex.props(styles.panelGrid)}>
<section {...stylex.props(styles.panel)}>
<h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
{timeseries.isError ? (
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
) : timeseries.data === undefined ? (
<Skeleton height={240} />
) : (
<TimeseriesChart data={timeseries.data} />
)}
</section>
{health.data === undefined ? <Skeleton height={160} /> : <DiskCard disk={health.data.disk} />}
</div>
{upstreamHealth.isError ? (
<InlineError error={upstreamHealth.error} onRetry={() => void upstreamHealth.refetch()} />
) : upstreamHealth.data === undefined ? (
<Skeleton height={120} />
) : (
<UpstreamHealthTable health={upstreamHealth.data} />
)}
</section>
);
}
-99
View File
@@ -1,99 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
heading: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
badge: {
borderRadius: "0.25rem",
paddingInline: "0.5rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/**
* The badge fills are their own three-step scale, not the `danger`/`warn`
* banner tokens: they read as a tinted chip against a raised card, where a
* banner fill would be too heavy.
*/
ok: {
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
warn: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
critical: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
row: {
display: "flex",
justifyContent: "space-between",
},
term: {
color: colors.textMuted,
},
});
function stateStyle(state: Health["disk"]["state"]) {
if (state === "critical") return styles.critical;
return state === "warn" ? styles.warn : styles.ok;
}
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
return (
<section {...stylex.props(styles.card)}>
{/* Live state, unlike the ranged widgets around it; the title says so
rather than a section rule the picker would have to disown. */}
<h2 {...stylex.props(styles.heading)}>
Storage now
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
</h2>
<dl {...stylex.props(styles.list)}>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Free</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Database</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Logs</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
</div>
</dl>
</section>
);
}
@@ -1,68 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
stack: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
banner: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
warn: {
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
},
critical: {
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
color: colors.dangerText,
},
});
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
return (
<p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
{children}
</p>
);
}
export default function HealthBanners({ health }: { health: Health }) {
const banners: React.ReactNode[] = [];
if (health.disk.state !== "ok") {
banners.push(
<Banner key="disk" tone={health.disk.state === "critical" ? "critical" : "warn"}>
{health.disk.state === "critical"
? `Disk critically low: ${formatBytes(health.disk.free_bytes)} free. Blocklist updates and log flushes are stopped.`
: `Disk space low: ${formatBytes(health.disk.free_bytes)} free.`}
</Banner>,
);
}
if (health.writer_failed) {
banners.push(
<Banner key="writer" tone="critical">
Query log writer failed; new queries are not being persisted.
</Banner>,
);
}
if (health.queries_dropped > 0) {
banners.push(
<Banner key="dropped" tone="warn">
{health.queries_dropped.toLocaleString()} queries dropped from the log buffer.
</Banner>,
);
}
if (banners.length === 0) return null;
return <div {...stylex.props(styles.stack)}>{banners}</div>;
}
@@ -1,85 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
},
},
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
value: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
detail: {
marginLeft: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
function percentOf(part: number, total: number): string | null {
if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`;
}
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
return (
<div {...stylex.props(styles.card)}>
<dt {...stylex.props(styles.label)}>{label}</dt>
<dd>
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd>
</div>
);
}
export default function StatCards({ stats }: { stats: StatsTotals }) {
return (
<dl {...stylex.props(styles.grid)}>
<Card label="Queries" value={numberFormat.format(stats.queries)} />
<Card
label="Blocked"
value={numberFormat.format(stats.blocked)}
detail={percentOf(stats.blocked, stats.queries)}
/>
<Card
label="Cached"
value={numberFormat.format(stats.cached)}
detail={percentOf(stats.cached, stats.queries)}
/>
<Card label="Clients" value={numberFormat.format(stats.clients)} />
<Card
label="Avg response"
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
/>
</dl>
);
}
@@ -1,165 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
import UpstreamHealthTable from "./UpstreamHealthTable";
const NOW_S = 1_700_000_000;
const ZERO: UpstreamPeriodStats = {
attempts: 0,
successes: 0,
failures: 0,
success_rate: null,
last_failure_at: null,
last_failure_error: null,
};
function period(overrides: Partial<UpstreamPeriodStats> = {}): UpstreamPeriodStats {
return {
attempts: 100,
successes: 90,
failures: 10,
success_rate: 0.9,
last_failure_at: NOW_S - 12_600,
last_failure_error: "Timeout",
...overrides,
};
}
function entry(overrides: Partial<UpstreamHealthEntry> = {}): UpstreamHealthEntry {
return {
url: "https://dns.example/dns-query",
enabled: true,
available: true,
period: period(),
...overrides,
};
}
function renderTable(upstreams: UpstreamHealthEntry[], overrides: Partial<UpstreamHealth> = {}) {
const health: UpstreamHealth = {
period: "24h",
since: NOW_S - 86_400,
until: NOW_S,
available: upstreams.filter((upstream) => upstream.available).length,
total: upstreams.length,
complete: true,
upstreams,
...overrides,
};
render(<UpstreamHealthTable health={health} />);
}
function rowOf(url: string): HTMLElement {
const cell = screen.getByText(url);
const row = cell.closest("tr");
if (row === null) throw new Error(`no row for ${url}`);
return row;
}
test("the ranged columns sit under a header naming the selected period", () => {
renderTable([entry()]);
expect(screen.getByRole("columnheader", { name: "Selected period · 24h" })).toBeTruthy();
for (const name of ["Upstream", "Status now", "Attempts", "Failures", "Success rate"]) {
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
}
// The unranged yes/no pair the ranged table replaced.
expect(screen.queryByRole("columnheader", { name: "Enabled" })).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Available" })).toBeNull();
});
test("failure detail is the Diagnostics page's job; the card never shows it", () => {
renderTable([entry()]);
expect(screen.queryByRole("columnheader", { name: "Last failure" })).toBeNull();
expect(screen.queryByText(/Timeout/)).toBeNull();
expect(screen.queryByText(/ago$/)).toBeNull();
});
test("status now is one word from live state, not from the window", () => {
renderTable([
entry({ url: "https://a.example/dns-query" }),
entry({ url: "https://b.example/dns-query", available: false }),
entry({ url: "https://c.example/dns-query", enabled: false, available: false }),
]);
expect(within(rowOf("https://a.example/dns-query")).getByText("Available")).toBeTruthy();
expect(within(rowOf("https://b.example/dns-query")).getByText("Backing off")).toBeTruthy();
expect(within(rowOf("https://c.example/dns-query")).getByText("Disabled")).toBeTruthy();
});
test("a window with no attempts renders an em-dash and never a perfect rate", () => {
renderTable([entry({ period: ZERO })]);
const cells = within(rowOf("https://dns.example/dns-query")).getAllByRole("cell");
expect(cells.map((cell) => cell.textContent)).toEqual([
"https://dns.example/dns-query",
"Available",
"0",
"0",
"—",
]);
expect(screen.queryByText("100.0%")).toBeNull();
expect(screen.queryByText("0.0%")).toBeNull();
});
test("the card says so when every upstream was idle in the window", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry({ period: ZERO })]);
expect(screen.getByText("No upstream attempts in this period.")).toBeTruthy();
});
test("one upstream with attempts keeps the idle message away", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry()]);
expect(screen.queryByText("No upstream attempts in this period.")).toBeNull();
});
test("an incomplete window carries a note; a complete one claims nothing", () => {
renderTable([entry()], { complete: false });
expect(screen.getByText(/history incomplete/i)).toBeTruthy();
});
test("a complete window shows no completeness text at all", () => {
renderTable([entry()], { complete: true });
expect(screen.queryByText(/history incomplete/i)).toBeNull();
expect(screen.queryByText(/complete/i)).toBeNull();
});
test("an empty pool says so instead of drawing a table", () => {
renderTable([]);
expect(screen.getByText("No upstreams configured.")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});
test("a rate a hair under perfect never rounds up to 100.0% while failures stand", () => {
// The real row that produced this: 12,698 attempts, 2 failures, 99.984%.
renderTable([
entry({
period: period({ attempts: 12_698, successes: 12_696, failures: 2, success_rate: 12_696 / 12_698 }),
}),
]);
expect(screen.queryByText("100.0%")).toBeNull();
expect(screen.getByText("99.9%")).toBeTruthy();
});
test("a rate a hair above nothing never rounds down to 0.0% while successes stand", () => {
renderTable([
entry({
period: period({ attempts: 12_698, successes: 2, failures: 12_696, success_rate: 2 / 12_698 }),
}),
]);
expect(screen.queryByText("0.0%")).toBeNull();
expect(screen.getByText("0.1%")).toBeTruthy();
});
test("a window with no failures at all still reads 100.0%", () => {
renderTable([entry({ period: period({ attempts: 500, successes: 500, failures: 0, success_rate: 1 }) })]);
expect(screen.getByText("100.0%")).toBeTruthy();
});
@@ -1,226 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
},
heading: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
paddingInline: "1rem",
paddingTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
count: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
empty: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
note: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tableWrap: {
overflowX: "auto",
},
table: {
marginTop: "0.5rem",
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/**
* The two live columns are left outside the span: everything under it answers
* for the selected window, and nothing else on this card does.
*/
groupRow: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
groupHead: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "1rem",
paddingBottom: "0.25rem",
textAlign: "center",
fontWeight: 500,
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
textAlign: "left",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
th: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontWeight: 500,
},
thRight: {
textAlign: "right",
},
/** No hairline under the last row: the card border already closes the table. */
row: {
borderBottomWidth: { default: 1, ":last-child": 0 },
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
},
cellRight: {
textAlign: "right",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
bad: {
color: colors.danger,
},
});
/** Live pool state in one word. Configuration first: a disabled upstream is not backing off. */
function statusNow(upstream: UpstreamHealthEntry): "Available" | "Backing off" | "Disabled" {
if (!upstream.enabled) return "Disabled";
return upstream.available ? "Available" : "Backing off";
}
/**
* `success_rate` is null exactly when the window holds no attempt, and that must
* not read as perfect reliability — hence the em-dash rather than `100.0%`.
*
* One decimal place cannot hold 12,696 of 12,698: it rounds to `100.0%`, and the
* row then claims perfection beside a failure count of 2. Neither endpoint may
* be reached by rounding — only by actually having no failure, or no success.
*/
function successRate(period: UpstreamPeriodStats): string {
if (period.success_rate === null) return "—";
const rounded = period.success_rate * 100;
if (rounded > 99.9 && period.failures > 0) return "99.9%";
if (rounded < 0.1 && period.successes > 0) return "0.1%";
return `${rounded.toFixed(1)}%`;
}
/**
* The dashboard answers availability only. Failure detail — what failed, when,
* and how often — is the Diagnostics page's job, so `last_failure_at` and
* `last_failure_error` are read there rather than repeated in this row.
*/
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
const idle = health.upstreams.length > 0 && health.upstreams.every(({ period }) => period.attempts === 0);
return (
<section {...stylex.props(styles.card)}>
<h2 {...stylex.props(styles.heading)}>
Upstreams
<span {...stylex.props(styles.count, shared.tabularNums)}>
{health.available}/{health.total} available
</span>
</h2>
{health.upstreams.length === 0 ? (
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
) : (
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.groupRow)}>
<td colSpan={2} />
<th scope="colgroup" colSpan={3} {...stylex.props(styles.groupHead)}>
Selected period · {health.period}
</th>
</tr>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.th)}>
Upstream
</th>
<th scope="col" {...stylex.props(styles.th)}>
Status now
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Attempts
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Failures
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Success rate
</th>
</tr>
</thead>
<tbody>
{health.upstreams.map((upstream) => {
const status = statusNow(upstream);
return (
<tr key={upstream.url} {...stylex.props(styles.row)}>
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>
{upstream.url}
</td>
<td {...stylex.props(styles.cell)}>
<span
{...stylex.props(
status === "Backing off" && styles.bad,
status === "Disabled" && styles.muted,
)}
>
{status}
</span>
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.attempts)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.failures)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{successRate(upstream.period)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{idle && <p {...stylex.props(styles.note)}>No upstream attempts in this period.</p>}
{!health.complete && (
<p {...stylex.props(styles.note)}>
History incomplete: outcomes were dropped in this window, so these counts are a lower bound.
</p>
)}
</section>
);
}
@@ -5,6 +5,7 @@ import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { DIAGNOSTIC_CODES, type DiagnosticEvent } from "@/lib/types";
import { health } from "@/lib/healthFixture";
import { EVENT_COPY } from "./eventCopy";
const NOW_S = Math.floor(Date.now() / 1000);
@@ -35,6 +36,8 @@ beforeEach(() => {
requested = [];
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
// The shell reads health for the Diagnostics nav badge on every route.
"/api/health": health(),
};
vi.stubGlobal(
"fetch",
@@ -4,6 +4,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { DiagnosticEvent, DiagnosticsPage } from "@/lib/types";
// Ages are rendered against the wall clock, so the fixtures are anchored to it
@@ -68,6 +69,9 @@ beforeEach(() => {
requested = [];
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
// The health strip at the top of the page; quiet on a healthy box, which is
// what every test below wants it to be.
"/api/health": health(),
"/api/diagnostics?state=active": ACTIVE,
"/api/diagnostics?state=resolved": RESOLVED,
};
@@ -256,7 +260,7 @@ test("only the resolved history offers a purge", async () => {
// 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")!;
const active = (await screen.findByText("Blocklist source failed to update")).closest("li")!;
expect(within(active).queryByRole("button", { name: "Purge" })).toBeNull();
const row = screen.getByText("Disk space low").closest("tr")!;
@@ -17,6 +17,7 @@ import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import HealthStrip from "./HealthStrip";
import SeverityBadge from "./SeverityBadge";
import { diagnosticsFilterOf } from "./filter";
import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy";
@@ -401,6 +402,8 @@ export default function DiagnosticsPage() {
repeats, and closes when the subject recovers.
</p>
<HealthStrip />
<RangeNotice since={search.since} until={search.until} />
<div {...stylex.props(styles.filterGrid)}>
@@ -0,0 +1,195 @@
/**
* The health strip on the Diagnostics page, through the real router.
*
* Its load contract is migrated whole from the deleted Overview status section:
* a visible loading state before the first reading, an error row with Retry when
* the first read fails, and a refetch failure that marks the conditions on
* screen as the last reading rather than the current state. What is new is that
* a condition explained on this page narrows this page instead of navigating.
*/
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 { health } from "@/lib/healthFixture";
import type { Health } from "@/lib/types";
let healthBody: Health;
let healthFails: boolean;
let requested: string[];
/** Held open to keep a health request in flight while a test looks at the strip. */
let pendingHealth: Promise<void> | null;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
healthBody = health();
healthFails = false;
requested = [];
pendingHealth = null;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
requested.push(url);
if (url === "/api/health") {
if (pendingHealth !== null) await pendingHealth;
return healthFails ? json({ error: "health unavailable" }, 400) : json(healthBody);
}
if (url === "/api/version")
return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 });
if (url.startsWith("/api/diagnostics"))
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
return json({ error: "not stubbed" }, 404);
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
function renderDiagnostics(path = "/diagnostics") {
const queryClient = createQueryClient();
const defaults = queryClient.getDefaultOptions();
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { router, queryClient };
}
function strip(): HTMLElement {
return screen.getByRole("list", { name: "Current status" });
}
function fact(label: string): HTMLElement {
// First match, not only match: a condition's label and the link it offers can
// be the same word — Upstreams links to Upstreams — and the label comes first.
const cell = within(strip()).getAllByText(label)[0];
const item = cell.closest("li");
if (item === null) throw new Error(`no health fact for ${label}`);
return item;
}
test("the five conditions are stated in words, healthy ones without a way out", async () => {
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
for (const [label, value] of [
["Protection", "Active"],
["Upstreams", "Available"],
["Query history", "Recording"],
["Diagnostics", "Recording"],
["Storage", "OK"],
] as const) {
expect(within(fact(label)).getByText(value)).toBeTruthy();
}
expect(within(strip()).queryByRole("link")).toBeNull();
});
test("protection unavailable sends the reader to Blocklists, upstreams to Upstreams", async () => {
healthBody = health({
protection: { state: "unavailable", until: null },
upstreams: { state: "unavailable", available: 0, total: 2 },
});
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Protection")).getByRole("link", { name: "Blocklists" }).getAttribute("href")).toBe(
"/blocklists",
);
expect(within(fact("Upstreams")).getByRole("link", { name: "Upstreams" }).getAttribute("href")).toBe("/upstreams");
});
test("a losing query log narrows this page to the disk, a failed writer to the query log", async () => {
healthBody = health({ query_history: { state: "losing", dropped_total: 4, last_drop_s: null } });
const { router } = renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Query history")).getByText("4 queries dropped")).toBeTruthy();
fireEvent.click(within(fact("Query history")).getByRole("link", { name: "Disk diagnostics" }));
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
await waitFor(() => expect(requested.some((url) => url.includes("component=disk"))).toBe(true));
});
test("a filter link drops a time window that would hide the episodes it points at", async () => {
healthBody = health({ disk: { state: "critical", free_bytes: 0 } });
const { router } = renderDiagnostics("/diagnostics?since=1000&until=2000&severity=error&state=resolved");
await waitFor(() => expect(strip()).toBeTruthy());
fireEvent.click(within(fact("Storage")).getByRole("link", { name: "Disk diagnostics" }));
// Everything that could hide the episode goes with the bounds: `state=resolved`
// would exclude the active disk episode this link exists to show, and an
// `error` severity would exclude it whenever it is a warning.
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
});
test("an unavailable diagnostics store explains itself and offers no link into itself", async () => {
healthBody = health({ diagnostics: { state: "unavailable", active_warnings: 0, active_errors: 0 } });
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
const row = fact("Diagnostics");
expect(within(row).getByText(/not being recorded/)).toBeTruthy();
expect(within(row).queryByRole("link")).toBeNull();
});
test("the strip says it is loading before the first reading, never empty conditions", async () => {
// Through the route, which is the path that matters: the loader starts the
// health request without waiting for it, so the page paints while the reading
// is still in flight and the strip has to say so.
let release = () => {};
pendingHealth = new Promise<void>((resolve) => {
release = resolve;
});
renderDiagnostics();
expect(await screen.findByText("Loading status…")).toBeTruthy();
expect(screen.queryByText("Protection")).toBeNull();
release();
await waitFor(() => expect(strip()).toBeTruthy());
expect(screen.queryByText("Loading status…")).toBeNull();
});
test("a failed first health read is an error row with Retry, not a healthy strip", async () => {
healthFails = true;
renderDiagnostics();
await screen.findByText("health unavailable");
expect(screen.queryByRole("list", { name: "Current status" })).toBeNull();
});
test("a reading that has gone stale says so rather than passing for current", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderDiagnostics();
await vi.waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
// The next poll fails. The conditions on screen are the last that arrived and
// must not keep passing for the current state.
healthFails = true;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.getByText(/last reading that arrived/)).toBeTruthy());
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
// Recovery clears the caption rather than leaving the page permanently unsure.
healthFails = false;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.queryByText(/last reading that arrived/)).toBeNull());
});
@@ -0,0 +1,178 @@
/**
* The five health conditions, compactly, at the top of the page that explains
* failures. Healthy conditions stay quiet; a degraded one is highlighted and
* offers the way out.
*
* A degraded condition whose explanation is on this page narrows this page
* rather than navigating away: the filter link sets `component` and drops every
* other filter. A time window, a severity or a `state=resolved` left from an
* earlier investigation would each hide the very episode the reader was sent to
* read, and a link that lands on "no events" states something false.
*
* The load contract is the one the deleted Overview status section carried: a
* visible loading state before the first reading, and a refetch failure that
* says so — the conditions on screen become the last reading that arrived, never
* a claim about the current state, until a poll succeeds again.
*/
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { healthQuery } from "@/lib/queries";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { healthFacts, type FactLink, type FactTone, type HealthFact } from "./healthFacts";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
list: {
marginTop: "0.75rem",
display: "grid",
gap: "0.5rem",
// Five is prime, so every count between one and five leaves a short last
// row; three columns made it 3 then 2, which reads as a layout that ran out
// of room rather than one that chose. So the strip goes from one column
// straight to two and then to a single row of five, and is never ragged.
gridTemplateColumns: {
default: "minmax(0, 1fr)",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1100px)": "repeat(5, minmax(0, 1fr))",
},
listStyleType: "none",
padding: 0,
},
fact: {
display: "flex",
flexWrap: "wrap",
alignItems: "baseline",
gap: "0.375rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "0.625rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** Quiet: a healthy condition is the normal state and gets no emphasis. */
quiet: {
borderColor: colors.border,
backgroundColor: "transparent",
},
highlighted: {
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
},
label: {
color: colors.textSecondary,
},
value: {
fontWeight: 500,
},
detail: {
flexBasis: "100%",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
link: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
ok: {
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
notice: {
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
warn: {
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
danger: {
color: colors.dangerText,
},
message: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
/** Text and icon carry the state; the colour only agrees with them. */
const ICONS: Record<FactTone, string> = { ok: "●", notice: "‖", warn: "!", danger: "✕" };
function FactLinkAnchor({ link }: { link: FactLink }) {
if (link.kind === "filter") {
return (
<Link
to="/diagnostics"
// Sets the component and clears every filter that could hide what it
// points at: a time window from an older investigation, and a severity
// or state — `resolved` above all — that would exclude the very episode
// explaining the condition this link came from.
search={() => ({ component: link.component })}
{...stylex.props(styles.link, shared.focusRing)}
>
{link.label}
</Link>
);
}
return (
<Link to={link.to} {...stylex.props(styles.link, shared.focusRing)}>
{link.label}
</Link>
);
}
function Fact({ fact }: { fact: HealthFact }) {
return (
<li {...stylex.props(styles.fact, fact.tone === "ok" ? styles.quiet : styles.highlighted)}>
<span aria-hidden="true" {...stylex.props(TONES[fact.tone])}>
{ICONS[fact.tone]}
</span>
<span {...stylex.props(styles.label)}>{fact.label}</span>
<span {...stylex.props(styles.value, TONES[fact.tone])}>{fact.value}</span>
{fact.link !== undefined && <FactLinkAnchor link={fact.link} />}
{fact.detail !== undefined && <span {...stylex.props(styles.detail)}>{fact.detail}</span>}
</li>
);
}
export default function HealthStrip() {
const health = useQuery(healthQuery());
if (health.data === undefined) {
return health.isError ? (
<InlineError error={health.error} onRetry={() => void health.refetch()} />
) : (
<p role="status" {...stylex.props(styles.message, shared.pulse)}>
Loading status
</p>
);
}
return (
<>
<ul aria-label="Current status" {...stylex.props(styles.list)}>
{healthFacts(health.data).map((fact) => (
<Fact key={fact.key} fact={fact} />
))}
</ul>
{health.isError && (
<>
<p role="status" {...stylex.props(styles.message)}>
This is the last reading that arrived. The current state is unknown.
</p>
<InlineError error={health.error} onRetry={() => void health.refetch()} />
</>
)}
</>
);
}
@@ -48,3 +48,20 @@ test("component labels read as prose without inventing a name", () => {
expect(componentLabel("query_log")).toBe("Query log");
expect(componentLabel("disk")).toBe("Disk");
});
test("the legacy upstream_history code keeps its copy, so a retained episode still reads as prose", () => {
// Nothing emits it any more, but stored rows outlive the subsystem and the
// list endpoint passes their codes through verbatim.
expect(DIAGNOSTIC_CODES).toContain("upstream_history.write");
const copy = EVENT_COPY["upstream_history.write"];
expect(copy.title).toBe("Upstream history write failed");
expect(copy.impact).toMatch(/no longer runs/);
});
test("the recreated-log copy covers a planned schema change as well as an unreadable file", () => {
const copy = EVENT_COPY["query_log.recreated"];
// The planned cause leads, because it is the one an upgrade produces; the
// unreadable file is the other cause and must not be dropped from the copy.
expect(copy.impact).toMatch(/schema/);
expect(copy.impact).toMatch(/could not be read/);
});
+9 -3
View File
@@ -111,14 +111,20 @@ export const EVENT_COPY: Record<DiagnosticCode, EventCopy> = {
},
"query_log.recreated": {
title: "Query log recreated",
impact: "The old log database was unreadable and was moved aside; the history it held is not in the new one.",
impact: "The old log database was moved aside — a release changed its schema, or the file could not be read — and the history it held is not in the new one.",
remediation: "Keep or delete the aside file named below. Nothing else is required — logging is running.",
link: SETTINGS,
},
/**
* Legacy. Nothing emits this code any more: milestone 30 deleted the
* upstream-minute history subsystem. Stored rows outlive it, and the list
* endpoint passes their codes through verbatim, so the copy stays — a
* retained episode must still read as prose rather than as a dotted string.
*/
"upstream_history.write": {
title: "Upstream history write failed",
impact: "Resolution is unaffected; the per-upstream success and failure aggregates lose the affected window.",
remediation: "Check free disk space and the configuration database's permissions.",
impact: "A recorded failure of a subsystem this version no longer runs. Resolution was unaffected; the per-upstream aggregates it fed are gone.",
remediation: "Nothing to do. Purge the entry once you have read it.",
link: UPSTREAMS,
},
"upstream.exchange": {
@@ -0,0 +1,97 @@
/**
* The health-fact matrix, migrated whole from the Overview status rows this
* replaces. Same states, same words, same link matrix — with the Diagnostics
* links now narrowing the page the strip sits on rather than navigating to it.
*/
import { health } from "@/lib/healthFixture";
import { healthFacts, type HealthFact } from "./healthFacts";
const LOCALE = "en-GB";
const TZ = "UTC";
function factsBy(overrides: Parameters<typeof health>[0] = {}): Record<string, HealthFact> {
return Object.fromEntries(healthFacts(health(overrides), LOCALE, TZ).map((fact) => [fact.key, fact]));
}
test("a healthy box is five quiet facts, none of them linking anywhere", () => {
const facts = healthFacts(health(), LOCALE, TZ);
expect(facts.map((fact) => fact.key)).toEqual(["protection", "upstreams", "query_history", "diagnostics", "disk"]);
expect(facts.every((fact) => fact.tone === "ok")).toBe(true);
expect(facts.every((fact) => fact.link === undefined)).toBe(true);
});
test("protection: active, paused indefinitely, paused until a time, unavailable", () => {
expect(factsBy()["protection"].value).toBe("Active");
expect(factsBy({ protection: { state: "paused", until: null } })["protection"].value).toBe("Paused");
const timed = factsBy({ protection: { state: "paused", until: Date.UTC(2026, 0, 1, 14, 5) / 1000 } })["protection"];
expect(timed.value).toBe("Paused until 14:05");
const gone = factsBy({ protection: { state: "unavailable", until: null } })["protection"];
expect(gone.value).toBe("Unavailable");
expect(gone.link).toEqual({ kind: "route", to: "/blocklists", label: "Blocklists" });
});
test("a pause never reads as a fault, and never carries a way out", () => {
const paused = factsBy({ protection: { state: "paused", until: null } })["protection"];
expect(paused.tone).toBe("notice");
expect(paused.link).toBeUndefined();
});
test("upstreams count the enabled pool, and only an empty one links out", () => {
const ok = factsBy({ upstreams: { state: "ok", available: 1, total: 3 } })["upstreams"];
expect(ok.value).toBe("Available");
expect(ok.detail).toBe("1 of 3 enabled");
expect(ok.link).toBeUndefined();
const none = factsBy({ upstreams: { state: "unavailable", available: 0, total: 3 } })["upstreams"];
expect(none.value).toBe("None reachable");
expect(none.link).toEqual({ kind: "route", to: "/upstreams", label: "Upstreams" });
});
test("query history: losing blames the disk gate, a failed writer blames the query log", () => {
const losing = factsBy({ query_history: { state: "losing", dropped_total: 0, last_drop_s: null } })[
"query_history"
];
expect(losing.value).toBe("Losing rows");
expect(losing.link).toEqual({ kind: "filter", component: "disk", label: "Disk diagnostics" });
const failed = factsBy({ query_history: { state: "failed", dropped_total: 0, last_drop_s: null } })[
"query_history"
];
expect(failed.value).toBe("Writer failed");
expect(failed.link).toEqual({ kind: "filter", component: "query_log", label: "Query log diagnostics" });
});
test("drops are reported while recording, with or without a stamp on the last one", () => {
const stamped = factsBy({
query_history: { state: "recording", dropped_total: 5, last_drop_s: Date.UTC(2026, 0, 1, 9, 30) / 1000 },
})["query_history"];
expect(stamped.tone).toBe("ok");
expect(stamped.detail).toBe("5 queries dropped, last at 09:30");
const unstamped = factsBy({ query_history: { state: "recording", dropped_total: 1, last_drop_s: null } })[
"query_history"
];
expect(unstamped.detail).toBe("1 query dropped");
expect(factsBy()["query_history"].detail).toBeUndefined();
});
test("an unavailable diagnostics store explains itself and links nowhere", () => {
const fact = factsBy({ diagnostics: { state: "unavailable", active_warnings: 0, active_errors: 0 } })[
"diagnostics"
];
expect(fact.value).toBe("Unavailable");
expect(fact.detail).toContain("not being recorded");
// The page a link would filter is the thing that is broken.
expect(fact.link).toBeUndefined();
});
test("storage names the three disk states and always shows what is free", () => {
expect(factsBy()["disk"].value).toBe("OK");
expect(factsBy()["disk"].link).toBeUndefined();
const low = factsBy({ disk: { state: "low", free_bytes: 1024 } })["disk"];
expect(low.value).toBe("Low");
expect(low.tone).toBe("warn");
expect(low.link).toEqual({ kind: "filter", component: "disk", label: "Disk diagnostics" });
const critical = factsBy({ disk: { state: "critical", free_bytes: 0 } })["disk"];
expect(critical.value).toBe("Critical");
expect(critical.tone).toBe("danger");
expect(critical.detail).toBe("0 B free");
});
@@ -0,0 +1,133 @@
/**
* The five conditions `GET /api/health` reports, as facts.
*
* A pure projection of `Health` so the whole matrix — every state of every
* condition, and every way out a degraded one offers — is testable without a
* router or a fetch. `HealthStrip` only paints what this returns.
*
* A healthy fact is quiet: no badge, no panel, no green reassurance, so the one
* condition that is not healthy is the thing the eye lands on.
*/
import { formatBytes, formatClock } from "@/lib/format";
import type { Health } from "@/lib/types";
export type FactTone = "ok" | "notice" | "warn" | "danger";
/**
* Where a degraded fact sends the reader. A `filter` link stays on this page and
* narrows it to the component that failed; a `route` link leaves for the surface
* that can fix the condition.
*/
export type FactLink =
| { kind: "route"; to: "/blocklists" | "/upstreams"; label: string }
| { kind: "filter"; component: string; label: string };
export interface HealthFact {
key: "protection" | "upstreams" | "query_history" | "diagnostics" | "disk";
label: string;
tone: FactTone;
/** The state, in the reader's words. Never colour alone. */
value: string;
detail?: string;
link?: FactLink;
}
const numberFormat = new Intl.NumberFormat();
/** Kept out of the state rule: rows are lost whether or not the box is losing them now. */
function dropText(dropped: number, lastDrop: number | null, locale?: string, timeZone?: string): string | undefined {
if (dropped <= 0) return undefined;
const count = `${numberFormat.format(dropped)} ${dropped === 1 ? "query" : "queries"} dropped`;
return lastDrop === null ? count : `${count}, last at ${formatClock(lastDrop, locale, timeZone)}`;
}
function protectionFact(protection: Health["protection"], locale?: string, timeZone?: string): HealthFact {
if (protection.state === "unavailable") {
return {
key: "protection",
label: "Protection",
tone: "danger",
value: "Unavailable",
detail: "No filter snapshot is published, so queries are not being filtered.",
link: { kind: "route", to: "/blocklists", label: "Blocklists" },
};
}
if (protection.state === "paused") {
return {
key: "protection",
label: "Protection",
tone: "notice",
value:
protection.until === null
? "Paused"
: `Paused until ${formatClock(protection.until, locale, timeZone)}`,
};
}
return { key: "protection", label: "Protection", tone: "ok", value: "Active" };
}
function queryHistoryFact(history: Health["query_history"], locale?: string, timeZone?: string): HealthFact {
const detail = dropText(history.dropped_total, history.last_drop_s, locale, timeZone);
if (history.state === "failed") {
return {
key: "query_history",
label: "Query history",
tone: "danger",
value: "Writer failed",
detail,
link: { kind: "filter", component: "query_log", label: "Query log diagnostics" },
};
}
if (history.state === "losing") {
// The disk gate is what is holding the writes back, and it may be the only
// thing that has reported: query_log itself need not have an episode open.
return {
key: "query_history",
label: "Query history",
tone: "danger",
value: "Losing rows",
detail,
link: { kind: "filter", component: "disk", label: "Disk diagnostics" },
};
}
return { key: "query_history", label: "Query history", tone: "ok", value: "Recording", detail };
}
export function healthFacts(health: Health, locale?: string, timeZone?: string): HealthFact[] {
const { upstreams, diagnostics, disk } = health;
return [
protectionFact(health.protection, locale, timeZone),
{
key: "upstreams",
label: "Upstreams",
tone: upstreams.state === "unavailable" ? "danger" : "ok",
value: upstreams.state === "unavailable" ? "None reachable" : "Available",
detail: `${upstreams.available} of ${upstreams.total} enabled`,
...(upstreams.state === "unavailable"
? { link: { kind: "route", to: "/upstreams", label: "Upstreams" } as FactLink }
: {}),
},
queryHistoryFact(health.query_history, locale, timeZone),
diagnostics.state === "unavailable"
? {
key: "diagnostics",
label: "Diagnostics",
tone: "danger",
value: "Unavailable",
// No link: the page it would filter is the thing that is broken.
detail: "Diagnostics are not being recorded. Check free disk space and the configuration database's permissions.",
}
: { key: "diagnostics", label: "Diagnostics", tone: "ok", value: "Recording" },
{
key: "disk",
label: "Storage",
tone: disk.state === "critical" ? "danger" : disk.state === "low" ? "warn" : "ok",
value: disk.state === "critical" ? "Critical" : disk.state === "low" ? "Low" : "OK",
detail: `${formatBytes(disk.free_bytes)} free`,
...(disk.state === "ok"
? {}
: { link: { kind: "filter", component: "disk", label: "Disk diagnostics" } as FactLink }),
},
];
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Client activity over the same window as the query-volume chart: one stacked
* series per named client, plus everything outside the top eight as "Other".
*
* The x-axis is the timeseries endpoint's own bucket alignment, so the two
* charts stack directly above one another and a spike in one is at the same
* horizontal position in the other. Colour keys on the client string, so a
* client that changes rank between polls keeps its colour.
*/
import { useEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { StatsClients } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { layoutStacked } from "./chartLayout";
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
const CHART_HEIGHT = 240;
const FALLBACK_WIDTH = 640;
const styles = stylex.create({
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: CHART_HEIGHT,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
root: {
position: "relative",
},
gridLine: {
stroke: colors.border,
},
axisLine: {
stroke: colors.borderStrong,
},
axisLabel: {
fill: colors.textMuted,
fontSize: "10px",
},
/** The hairline separating touching segments is the page ground, not a colour. */
segment: {
stroke: colors.surface,
},
legend: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
listStyleType: "none",
padding: 0,
margin: 0,
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
legendItem: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
swatch: {
display: "inline-block",
width: "0.625rem",
height: "0.625rem",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the colour the bars are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
});
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (el === null) return;
setWidth(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, width];
}
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
function formatTick(ts: number, bucketSeconds: number): string {
const date = new Date(ts * 1000);
if (bucketSeconds >= 86_400) {
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
}
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
}
interface Series {
key: string;
label: string;
/** Kept beside the label so a renamed client is still identifiable by address. */
address: string | null;
color: string;
buckets: number[];
}
/**
* "Other" last, so it sits at the top of every column rather than under a
* client, and always present: the response always carries the series, and a
* legend that dropped it on a quiet period would make the reader think the
* chart's clients were all of them.
*/
function seriesOf(data: StatsClients, names: ClientNames): Series[] {
const named = data.clients.map((client) => ({
key: clientKey(client.client),
// The name if the client is registered under one, the address otherwise —
// the same precedence and the same lookup the query tables use. The colour
// keys on the address regardless, so naming a client never repaints it.
label: clientLabel(client.client, names)?.text ?? client.client,
address: client.client,
color: seriesColor(clientKey(client.client)),
buckets: client.buckets,
}));
return [
...named,
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
];
}
export default function ClientChart({ data }: { data: StatsClients }) {
const [containerRef, measuredWidth] = useContainerWidth();
const names = useClientNames();
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
const series = seriesOf(data, names);
const bucketCount = data.other.length;
if (bucketCount === 0) {
return (
<div ref={containerRef} {...stylex.props(styles.empty)}>
No queries in this period.
</div>
);
}
const columns = Array.from({ length: bucketCount }, (_, i) => ({
ts: data.since + i * data.bucket_seconds,
values: series.map((one) => one.buckets[i] ?? 0),
}));
if (columns.every((column) => column.values.every((value) => value === 0))) {
return (
<div ref={containerRef} {...stylex.props(styles.empty)}>
No queries in this period.
</div>
);
}
const layout = layoutStacked(columns, width, CHART_HEIGHT);
const baseline = layout.plot.y + layout.plot.height;
return (
<div ref={containerRef} {...stylex.props(styles.root)}>
<svg
role="img"
aria-label={`Client activity over time, ${bucketCount} buckets, ${series.length} series`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
>
{layout.yTicks.map((tick) => (
<g key={tick.value}>
<line
x1={layout.plot.x}
x2={layout.plot.x + layout.plot.width}
y1={tick.y}
y2={tick.y}
{...stylex.props(styles.gridLine)}
/>
<text
x={layout.plot.x - 6}
y={tick.y}
textAnchor="end"
dominantBaseline="middle"
{...stylex.props(styles.axisLabel, shared.tabularNums)}
>
{compact.format(tick.value)}
</text>
</g>
))}
<line
x1={layout.plot.x}
x2={layout.plot.x + layout.plot.width}
y1={baseline}
y2={baseline}
{...stylex.props(styles.axisLine)}
/>
{layout.xTicks.map((tick) => (
<text
key={tick.ts}
x={tick.x}
y={baseline + 14}
textAnchor="middle"
{...stylex.props(styles.axisLabel)}
>
{formatTick(tick.ts, data.bucket_seconds)}
</text>
))}
{layout.columns.map((column) => (
<g key={column.ts}>
{column.segments.map((rect, index) =>
rect.height <= 0 ? null : (
<rect
key={series[index].key}
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
fill={series[index].color}
strokeWidth={rect.width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/>
),
)}
<rect
x={column.slot.x}
y={column.slot.y}
width={column.slot.width}
height={column.slot.height}
fill="transparent"
>
<title>
{`${formatTime(column.ts)}: ${column.total} ${column.total === 1 ? "query" : "queries"}`}
</title>
</rect>
</g>
))}
</svg>
<ul {...stylex.props(styles.legend)}>
{series.map((one) => (
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
{one.label}
</li>
))}
</ul>
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>Queries per client per time bucket</caption>
<thead>
<tr>
<th scope="col">Time</th>
{series.map((one) => (
<th key={one.key} scope="col">
{one.label}
</th>
))}
</tr>
</thead>
<tbody>
{columns.map((column) => (
<tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th>
{column.values.map((value, index) => (
<td key={series[index].key}>{value}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
/**
* A breakdown as a ring, a legend and a table.
*
* The ring is decoration: it carries `aria-hidden` and `focusable="false"`,
* because a non-focusable SVG is still in the accessibility tree and would
* announce a pile of unlabelled paths. Everything the ring says is said again in
* the legend — visibly, with the share and the count — and once more in a
* visually hidden table, which is the surface a screen reader reads.
*
* Labels can collide: two rows can both be "Unknown", and one upstream name can
* appear under two route kinds. Identity is therefore the caller's `key`, and an
* entry that needs disambiguating carries `secondary` text saying which it is.
*/
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { layoutDonut, type DonutSlice } from "./donutLayout";
const SIZE = 180;
const THICKNESS = 36;
const numberFormat = new Intl.NumberFormat();
/** The width at which the page puts the two donuts side by side, and the page's
* own grid switches on the same query. StyleX will not take it from an import,
* so it is written out in both modules and must be changed in both. */
const TWO_COLUMN = "@media (min-width: 1280px)";
const styles = stylex.create({
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: SIZE,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/**
* Centred while the panels are stacked, left-anchored once they are side by
* side. Stacked, the panel is as wide as the page and a ring pinned to the
* left edge reads as a mistake; in a column it is one of a pair and lines up
* with everything above it.
*/
body: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" },
gap: "1.25rem",
},
ring: {
flexShrink: 0,
},
/**
* Capped and left-anchored. Without the cap the row justifies across whatever
* the panel is given — most of a metre of whitespace on a wide monitor — and a
* label stops reading as belonging to the count opposite it.
*/
legend: {
flex: 1,
minWidth: "12rem",
maxWidth: "24rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
listStyleType: "none",
padding: 0,
margin: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
legendItem: {
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
},
swatch: {
flexShrink: 0,
alignSelf: "center",
display: "inline-block",
width: "0.625rem",
height: "0.625rem",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the colour the ring is drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
label: {
flex: 1,
minWidth: 0,
overflowWrap: "anywhere",
},
secondary: {
marginLeft: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
count: {
color: colors.textSecondary,
},
share: {
minWidth: "3rem",
textAlign: "right",
color: colors.textMuted,
},
});
function sharePercent(share: number): string {
return `${(share * 100).toFixed(1)}%`;
}
export default function Donut({
slices,
caption,
unit,
}: {
slices: DonutSlice[];
/** Names the hidden table, so a screen reader knows which breakdown it is in. */
caption: string;
/** The column header for the counted thing, e.g. "Queries". */
unit: string;
}) {
const layout = layoutDonut(slices, SIZE, THICKNESS);
if (layout.total === 0) {
return <div {...stylex.props(styles.empty)}>No queries in this period.</div>;
}
return (
<div {...stylex.props(styles.body)}>
<svg
aria-hidden="true"
focusable="false"
width={SIZE}
height={SIZE}
viewBox={`0 0 ${SIZE} ${SIZE}`}
{...stylex.props(styles.ring)}
>
{layout.arcs.map((arc) => (
// The stroke is what keeps a shared hue from lying. Colour is a pure
// function of identity, so two neighbouring slices can come out the
// same; outlined in the panel's own colour they still read as two
// shapes rather than merging into one. Attributes rather than a
// class, as the client chart's segments are, so the separation is
// visible to a test and not only to a stylesheet.
<path
key={arc.slice.key}
d={arc.d}
fill={arc.slice.color}
fillRule="evenodd"
stroke={colors.surfaceRaised}
strokeWidth={1}
/>
))}
</svg>
<ul {...stylex.props(styles.legend)}>
{layout.arcs.map((arc) => (
<li key={arc.slice.key} {...stylex.props(styles.legendItem)}>
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchColor(arc.slice.color))}
/>
<span {...stylex.props(styles.label)}>
{arc.slice.label}
{arc.slice.secondary !== undefined && (
<span {...stylex.props(styles.secondary)}>{arc.slice.secondary}</span>
)}
</span>
<span {...stylex.props(styles.count, shared.tabularNums)}>
{numberFormat.format(arc.slice.value)}
</span>
<span {...stylex.props(styles.share, shared.tabularNums)}>{sharePercent(arc.share)}</span>
</li>
))}
</ul>
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>{caption}</caption>
<thead>
<tr>
<th scope="col">Entry</th>
<th scope="col">{unit}</th>
<th scope="col">Share</th>
</tr>
</thead>
<tbody>
{layout.arcs.map((arc) => (
<tr key={arc.slice.key}>
<th scope="row">
{arc.slice.secondary === undefined
? arc.slice.label
: `${arc.slice.label} (${arc.slice.secondary})`}
</th>
<td>{arc.slice.value}</td>
<td>{sharePercent(arc.share)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,400 @@
/**
* Overview through the real router: Pi-hole's layout over our data.
*
* The behaviours of the superseded three-section build are accounted for here or
* declared dead. The status rows and the issues list moved to the Diagnostics
* page's health strip and its Active section; the Pause control moved to the
* sidebar; the protection indicator is gone. What stays here is the period, the
* window and the panels.
*/
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 { clientKey, seriesColor } from "./seriesColors";
import { health } from "@/lib/healthFixture";
import type { Health, StatsClients, StatsRoutes, StatsTimeseries, StatsTotals, StatsTypes } from "@/lib/types";
const SINCE = Date.UTC(2026, 0, 1, 0, 0) / 1000;
const UNTIL = Date.UTC(2026, 0, 2, 0, 0) / 1000;
const COVERAGE = { complete: true, available_since: SINCE };
const TOTALS: StatsTotals = {
period: "24h",
since: SINCE,
until: UNTIL,
queries: 1000,
blocked: 250,
clients: 7,
avg_response_time_us: 2345,
coverage: COVERAGE,
};
const SERIES: StatsTimeseries = {
period: "24h",
since: SINCE,
until: UNTIL,
bucket_seconds: 1800,
buckets: [
{ ts: SINCE, queries: 60, blocked: 20, cached: 10 },
{ ts: SINCE + 1800, queries: 40, blocked: 0, cached: 0 },
],
coverage: COVERAGE,
};
const CLIENTS: StatsClients = {
period: "24h",
since: SINCE,
until: UNTIL,
bucket_seconds: 1800,
clients: [
{ client: "192.0.2.30", buckets: [40, 20] },
{ client: "192.0.2.31", buckets: [20, 20] },
],
other: [0, 0],
coverage: COVERAGE,
};
const TYPES: StatsTypes = {
period: "24h",
since: SINCE,
until: UNTIL,
types: [
{ qtype: 1, count: 600 },
{ qtype: 28, count: 300 },
{ qtype: null, count: 100 },
],
coverage: COVERAGE,
};
const ROUTES: StatsRoutes = {
period: "24h",
since: SINCE,
until: UNTIL,
routes: [
{ route: "upstream", source: "https://dns.example/dns-query", count: 500 },
{ route: "blocked", source: null, count: 250 },
{ route: "cache", source: null, count: 150 },
{ route: "upstream", source: null, count: 100 },
],
coverage: COVERAGE,
};
/** The same shapes an hour wide, so a period change is observable in every panel. */
const HOUR = {
totals: { ...TOTALS, period: "1h", since: UNTIL - 3600, queries: 12, blocked: 3, clients: 2 } as StatsTotals,
timeseries: { ...SERIES, period: "1h", since: UNTIL - 3600, bucket_seconds: 60, buckets: [] } as StatsTimeseries,
clients: { ...CLIENTS, period: "1h", since: UNTIL - 3600, clients: [], other: [] } as StatsClients,
types: { ...TYPES, period: "1h", since: UNTIL - 3600, types: [] } as StatsTypes,
routes: { ...ROUTES, period: "1h", since: UNTIL - 3600, routes: [] } as StatsRoutes,
};
let healthBody: Health;
let failing: Set<string>;
/** The registered clients, as `/api/clients` answers them. */
let registered: { ip: string; name: string; learned_name: string }[];
let coverageComplete: boolean;
/** Paths held in flight, so a test can look at the page while one is pending. */
let delayed: Map<string, Promise<void>>;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
function withCoverage<T extends { coverage: typeof COVERAGE }>(body: T): T {
return { ...body, coverage: { ...body.coverage, complete: coverageComplete } };
}
beforeEach(() => {
healthBody = health();
failing = new Set();
registered = [];
coverageComplete = true;
delayed = new Map();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const hour = url.includes("period=1h");
for (const [path, body] of [
["/api/stats/timeseries", hour ? HOUR.timeseries : SERIES],
["/api/stats/clients", hour ? HOUR.clients : CLIENTS],
["/api/stats/types", hour ? HOUR.types : TYPES],
["/api/stats/routes", hour ? HOUR.routes : ROUTES],
["/api/stats", hour ? HOUR.totals : TOTALS],
] as const) {
if (!url.startsWith(path)) continue;
if (failing.has(path)) return json({ error: "endpoint unavailable" }, 400);
const held = delayed.get(path);
if (held !== undefined) await held;
return json(withCoverage(body));
}
if (url === "/api/clients") {
return json({
clients: registered.map((client, index) => ({
id: index + 1,
ip: client.ip,
name: client.name,
learned_name: client.learned_name,
group_id: 1,
group: "default",
hand_edited: client.name !== "",
first_seen: SINCE,
last_seen: UNTIL,
})),
});
}
if (url === "/api/health") return json(healthBody);
if (url === "/api/version")
return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 });
if (url.startsWith("/api/diagnostics")) {
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
}
return json({ error: "not stubbed" }, 404);
}),
);
});
afterEach(() => vi.unstubAllGlobals());
function renderApp(path = "/overview") {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
function panel(name: string): HTMLElement {
const heading = screen.getByRole("heading", { name });
const section = heading.closest("section");
if (section === null) throw new Error(`no panel for ${name}`);
return section;
}
test("the root path lands on Overview rather than aliasing it", async () => {
const router = renderApp("/");
await screen.findByRole("heading", { name: "Overview", level: 1 });
expect(router.state.location.pathname).toBe("/overview");
});
test("every donut arc is outlined, so two slices of one hue still read as two", async () => {
// Colour is a pure function of identity and so cannot rule out two slices of
// one panel sharing a hue. The stroke is what stops neighbours from merging
// into one shape, which makes it part of the contract rather than decoration.
renderApp();
await screen.findByText("1,000");
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
const arcs = Array.from(panel("Query types").querySelectorAll("svg path"));
expect(arcs).toHaveLength(3);
for (const arc of arcs) {
expect(arc.getAttribute("stroke-width")).toBe("1");
// The panel's own surface colour, as a token reference.
expect(arc.getAttribute("stroke")).toMatch(/^var\(--/);
}
});
test("a slow endpoint does not hold the page back: the panels that answered render beside it", async () => {
// Through the real route, which is the point: the loader starts the five
// requests and awaits none of them. If it awaited, the router would hold the
// whole page until the slowest answered and this would time out on the tiles.
let release = () => {};
delayed.set("/api/stats/routes", new Promise<void>((resolve) => (release = resolve)));
renderApp();
// The tiles and both charts are readable while the routes request is still
// in flight, and the panel waiting on it says so for itself.
await screen.findByText("1,000");
expect(within(panel("Queries over time")).getAllByText("Blocked").length).toBeGreaterThan(0);
expect(within(panel("Client activity over time")).getAllByText("192.0.2.30")).toHaveLength(2);
expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2);
expect(within(panel("Upstream servers")).getByRole("status").textContent).toBe("Loading…");
release();
await waitFor(() => expect(within(panel("Upstream servers")).queryByRole("status")).toBeNull());
});
test("a registered client is named in the chart, an unregistered one keeps its address", async () => {
// The fixture's two clients: one registered with a typed name, one the clients
// list has never seen.
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
renderApp();
const chart = await waitFor(() => panel("Client activity over time"));
// Legend and the hidden table both, since the table is what a screen reader
// gets instead of the graphic and the two must not name one client differently.
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
});
test("a client named only by reverse DNS is named by it too", async () => {
registered = [{ ip: "192.0.2.31", name: "", learned_name: "laptop.lan" }];
renderApp();
const chart = await waitFor(() => panel("Client activity over time"));
await waitFor(() => expect(within(chart).getAllByText("laptop.lan")).toHaveLength(2));
});
test("naming a client does not recolour its series", async () => {
// The rename the palette must not notice: the swatch beside "kitchen-pi" is
// the colour of the address it was drawn under, not of the label on screen.
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
renderApp();
const chart = await waitFor(() => panel("Client activity over time"));
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
const item = within(chart).getAllByText("kitchen-pi")[0].closest("li") as HTMLElement;
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
expect(swatch.getAttribute("style")).toContain(seriesColor(clientKey("192.0.2.30")));
});
test("the client chart names Other even in a period where it counted nothing", async () => {
// The fixture's other series is all zeroes. Dropping it from the legend there
// would tell the reader the two named clients were every client.
renderApp();
await screen.findByRole("heading", { name: "Client activity over time" });
const chart = screen.getByRole("heading", { name: "Client activity over time" }).closest("section");
expect(chart).toBeTruthy();
// Twice each: the legend swatch and the column header of the table a screen
// reader gets instead of the graphic.
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("Other")).toHaveLength(2));
expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2);
});
test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => {
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
await screen.findByText("1,000");
for (const name of ["Queries over time", "Client activity over time", "Query types", "Upstream servers"]) {
expect(screen.getByRole("heading", { name })).toBeTruthy();
}
// The sections the layout ruling removed, and the widgets the Dashboard lost.
expect(screen.queryByRole("heading", { name: "Current status" })).toBeNull();
expect(screen.queryByRole("heading", { name: "Active issues" })).toBeNull();
expect(screen.queryByRole("heading", { name: "Activity over a period" })).toBeNull();
expect(screen.queryByText("Storage now")).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Upstream" })).toBeNull();
});
test("the four tiles report the window, and each links where its number leads", async () => {
renderApp();
const tiles = within((await screen.findByText("1,000")).closest("dl") as HTMLElement);
expect(tiles.getByText("250")).toBeTruthy();
expect(tiles.getByText("25.0%")).toBeTruthy();
expect(tiles.getByText("7")).toBeTruthy();
expect(tiles.getByText("2.3 ms")).toBeTruthy();
// The bounds are the ones the stats response returned, not ones computed here.
const queries = new URLSearchParams(
screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "",
);
expect(queries.get("mode")).toBe("history");
expect(queries.get("since")).toBe(String(SINCE));
expect(queries.get("until")).toBe(String(UNTIL));
expect(queries.get("blocked")).toBeNull();
const blocked = new URLSearchParams(
screen.getByRole("link", { name: "Open blocked queries" }).getAttribute("href")?.split("?")[1] ?? "",
);
expect(blocked.get("blocked")).toBe("true");
expect(blocked.get("since")).toBe(String(SINCE));
expect(screen.getByRole("link", { name: "Manage clients" }).getAttribute("href")).toBe("/clients");
// Average response time has no rows behind it to open.
expect(screen.queryByRole("link", { name: /average/i })).toBeNull();
});
test("both donuts name every entry, nulls included, and disambiguate a nameless source", async () => {
renderApp();
await screen.findByText("1,000");
const types = within(panel("Query types"));
expect(types.getByRole("rowheader", { name: "A" })).toBeTruthy();
expect(types.getByRole("rowheader", { name: "AAAA" })).toBeTruthy();
// A query whose type was never recorded is its own entry, not a dropped row.
expect(types.getByRole("rowheader", { name: "Unknown" })).toBeTruthy();
const routes = within(panel("Upstream servers"));
expect(routes.getByRole("rowheader", { name: "https://dns.example/dns-query (Upstream)" })).toBeTruthy();
expect(routes.getByRole("rowheader", { name: "Blocked" })).toBeTruthy();
expect(routes.getByRole("rowheader", { name: "Cache" })).toBeTruthy();
// An upstream row with no recorded resolver reads as Unknown, qualified by its kind.
expect(routes.getByRole("rowheader", { name: "Unknown (Upstream)" })).toBeTruthy();
});
test("the donut ring is decoration; the legend and the hidden table are the accessible surface", async () => {
renderApp();
await screen.findByText("1,000");
const svg = panel("Query types").querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
expect(svg?.getAttribute("focusable")).toBe("false");
expect(within(panel("Query types")).getByRole("table")).toBeTruthy();
});
test("an empty window says so in every panel instead of drawing nothing", async () => {
renderApp("/overview?period=1h");
await screen.findByText("12");
// The two donuts and the client chart; the query-volume chart says it too.
expect(screen.getAllByText("No queries in this period.").length).toBe(4);
});
test("a deep link opens on the period it names", async () => {
renderApp("/overview?period=1h");
await screen.findByText("12");
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
});
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
const router = renderApp("/overview?period=90d");
await screen.findByText("1,000");
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true");
expect(router.state.location.search).toEqual({});
});
test("the picker rescopes every panel and writes the period into the url", async () => {
const router = renderApp();
await screen.findByText("1,000");
fireEvent.click(screen.getByRole("button", { name: "1h" }));
await screen.findByText("12");
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
// No panel is left describing the period the reader left.
expect(screen.queryByText("1,000")).toBeNull();
});
test("one failing panel keeps its own error and leaves the rest of the page standing", async () => {
failing.add("/api/stats/routes");
renderApp();
await screen.findByText("1,000");
await waitFor(() => expect(within(panel("Upstream servers")).getByText("endpoint unavailable")).toBeTruthy());
expect(within(panel("Upstream servers")).getByRole("button", { name: "Retry" })).toBeTruthy();
// A failed donut never blanks the charts.
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByRole("img", { name: /client activity over time/i })).toBeTruthy();
expect(screen.queryByText("Something went wrong")).toBeNull();
});
test("an incomplete window states its watermark once for the whole page", async () => {
coverageComplete = false;
renderApp();
await screen.findByText("1,000");
expect(screen.getAllByText(/Query history is available from/)).toHaveLength(1);
});
@@ -0,0 +1,248 @@
/**
* Overview: what the resolver did over a period the reader chooses, in the
* layout Pi-hole's dashboard established — four totals, two full-width charts,
* two breakdown donuts. Nothing on this page is a current-state readout; the
* five health conditions live on Diagnostics, and protection lives in the
* sidebar beside its control.
*
* The period is URL state, so a view is a link: `/overview?period=1h` opens
* exactly what the sender was reading.
*
* Every panel reads the same window (`overviewWindow.ts`) and renders on its
* own. A donut whose request failed shows its own error while the charts keep
* their data, and no two panels ever describe different spans.
*/
import * as stylex from "@stylexjs/stylex";
import { useNavigate, useSearch } from "@tanstack/react-router";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { qtypeName } from "@/features/queries/qtype";
import type { Period, StatsRoutes, StatsTypes } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import ClientChart from "./ClientChart";
import Donut from "./Donut";
import StatTiles from "./StatTiles";
import TimeseriesChart from "./TimeseriesChart";
import type { DonutSlice } from "./donutLayout";
import { useOverviewWindow, type Panel } from "./overviewWindow";
import { DEFAULT_PERIOD, PERIODS } from "./period";
import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
/**
* Where the two donuts stop competing for width and sit side by side. `Donut`
* carries the same query for the alignment it switches at that width; StyleX
* requires the string to be a literal in the module that uses it, so the two
* agree by inspection rather than by sharing a constant.
*/
const TWO_COLUMN = "@media (min-width: 1280px)";
const ROUTE_LABELS = {
blocked: "Blocked",
cache: "Cache",
local: "Local",
rejected: "Rejected",
upstream: "Upstream",
forward_zone: "Forward zone",
} as const;
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
headingRow: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
period: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
donutRow: {
display: "grid",
gap: "1rem",
gridTemplateColumns: { default: "minmax(0, 1fr)", [TWO_COLUMN]: "repeat(2, minmax(0, 1fr))" },
},
loading: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
{...stylex.props(
styles.period,
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
>
{option}
</button>
))}
</div>
);
}
/**
* One panel's three states. Loading and error are the panel's own: a failure
* here never reaches past this box, which is what keeps a failed donut from
* blanking the charts beside it.
*/
function PanelBody<T>({ panel, children }: { panel: Panel<T>; children: (data: T) => React.ReactNode }) {
if (panel.status === "error") return <InlineError error={panel.error} onRetry={panel.retry} />;
if (panel.status === "loading") {
return (
<p role="status" {...stylex.props(styles.loading, shared.pulse)}>
Loading
</p>
);
}
return <>{children(panel.data)}</>;
}
function typeSlices(data: StatsTypes): DonutSlice[] {
return data.types.map((row) => ({
key: qtypeKey(row.qtype),
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
value: row.count,
color: seriesColor(qtypeKey(row.qtype)),
}));
}
/**
* Route slices. A row that names a resolver or a zone is labelled by that name
* with the route kind as secondary text, because one name can legitimately
* appear under two kinds and two rows can both be "Unknown". The four
* source-less kinds are their own label and need no qualifier.
*/
function routeSlices(data: StatsRoutes): DonutSlice[] {
return data.routes.map((row) => {
const named = row.route === "upstream" || row.route === "forward_zone";
return {
key: routeKey(row.route, row.source),
label: named ? (row.source ?? "Unknown") : ROUTE_LABELS[row.route],
...(named ? { secondary: ROUTE_LABELS[row.route] } : {}),
value: row.count,
color: seriesColor(routeKey(row.route, row.source)),
};
});
}
export default function OverviewPage() {
const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
const navigate = useNavigate({ from: "/overview" });
const overview = useOverviewWindow(period);
return (
<div {...stylex.props(styles.page)}>
<div {...stylex.props(styles.headingRow)}>
<h1 {...stylex.props(styles.heading)}>Overview</h1>
<PeriodPicker
period={period}
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
/>
</div>
<PanelBody panel={overview.totals}>{(totals) => <StatTiles stats={totals} />}</PanelBody>
{/* One notice for the page: every panel is judged against the same window,
so a second copy would only repeat this sentence. */}
{overview.coverage !== null && <CoverageNotice coverage={overview.coverage} />}
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
Queries over time
</h2>
<PanelBody panel={overview.timeseries}>{(data) => <TimeseriesChart data={data} />}</PanelBody>
</section>
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
Client activity over time
</h2>
<PanelBody panel={overview.clients}>{(data) => <ClientChart data={data} />}</PanelBody>
</section>
<div {...stylex.props(styles.donutRow)}>
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
Query types
</h2>
<PanelBody panel={overview.types}>
{(data) => <Donut slices={typeSlices(data)} caption="Queries by DNS type" unit="Queries" />}
</PanelBody>
</section>
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
Upstream servers
</h2>
<PanelBody panel={overview.routes}>
{(data) => (
<Donut
slices={routeSlices(data)}
caption="Queries by how they were answered"
unit="Queries"
/>
)}
</PanelBody>
</section>
</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
/**
* The window's four headline numbers, each with the way into the rows behind it.
*
* Neutral chrome throughout: no coloured accents, no per-tile tone. Emphasis is
* typographic, so the eye ranks the figures rather than the panels, and a tile
* never implies a state it is not reporting.
*
* The Activity links carry the bounds the **stats response** returned, not
* bounds computed here — a client-computed window would send the reader to a
* slightly different span than the one they were just reading.
*/
import * as stylex from "@stylexjs/stylex";
import { Link } from "@tanstack/react-router";
import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
/** Two columns on a phone, the whole set of four in one row from `md`. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(4, minmax(0, 1fr))",
},
},
tile: {
display: "flex",
flexDirection: "column",
gap: "0.125rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
valueRow: {
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
flexWrap: "wrap",
},
value: {
fontSize: "1.875rem",
lineHeight: "2.25rem",
fontWeight: 600,
},
detail: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textSecondary,
},
footer: {
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
},
link: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
});
function percentOf(part: number, total: number): string | null {
if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`;
}
function Tile({
label,
value,
detail,
footer,
}: {
label: string;
value: string;
detail?: string | null;
footer?: React.ReactNode;
}) {
return (
<div {...stylex.props(styles.tile)}>
<dt {...stylex.props(styles.label)}>{label}</dt>
<dd {...stylex.props(styles.valueRow)}>
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd>
{footer !== undefined && <div {...stylex.props(styles.footer)}>{footer}</div>}
</div>
);
}
export default function StatTiles({ stats }: { stats: StatsTotals }) {
const window = {
mode: "history" as const,
since: stats.since,
until: stats.until,
domain: undefined,
client: undefined,
};
return (
<dl {...stylex.props(styles.grid)}>
<Tile
label="Queries"
value={numberFormat.format(stats.queries)}
footer={
<Link
to="/activity"
search={{ ...window, blocked: undefined }}
{...stylex.props(styles.link, shared.focusRing)}
>
Open in Activity
</Link>
}
/>
<Tile
label="Blocked"
value={numberFormat.format(stats.blocked)}
detail={percentOf(stats.blocked, stats.queries)}
footer={
<Link
to="/activity"
search={{ ...window, blocked: true }}
{...stylex.props(styles.link, shared.focusRing)}
>
Open blocked queries
</Link>
}
/>
<Tile
label="Clients"
value={numberFormat.format(stats.clients)}
footer={
<Link to="/clients" {...stylex.props(styles.link, shared.focusRing)}>
Manage clients
</Link>
}
/>
<Tile
label="Avg response"
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
/>
</dl>
);
}
@@ -52,13 +52,97 @@ export function isEmptyTimeseries(buckets: Bucket[]): boolean {
return buckets.every((bucket) => bucket.queries === 0);
}
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
const plot: Rect = {
function plotRect(width: number, height: number): Rect {
return {
x: MARGIN.left,
y: MARGIN.top,
width: Math.max(0, width - MARGIN.left - MARGIN.right),
height: Math.max(0, height - MARGIN.top - MARGIN.bottom),
};
}
/**
* How many columns share one x-axis label. A 30-day window is 30 columns and a
* 1-hour window is 60, so at narrow widths the labels have to thin out rather
* than overprint each other.
*/
function labelStepFor(count: number, plotWidth: number): number {
if (count === 0 || plotWidth <= 0) return 1;
return Math.max(1, Math.ceil((count * MIN_X_LABEL_PX) / plotWidth));
}
/** One stacked column: the series values in the order the caller stacks them. */
export interface StackedColumn {
ts: number;
total: number;
slot: Rect;
segments: Rect[];
}
export interface StackedLayout {
width: number;
height: number;
plot: Rect;
scaleMax: number;
columns: StackedColumn[];
yTicks: { value: number; y: number }[];
xTicks: { ts: number; x: number }[];
}
/**
* The same geometry as the query-volume chart, for an arbitrary number of
* series. The scale comes from the tallest column's own total, because every
* series here is a disjoint part of the whole rather than a highlighted subset
* of a separately reported total.
*/
export function layoutStacked(
columns: { ts: number; values: number[] }[],
width: number,
height: number,
): StackedLayout {
const plot = plotRect(width, height);
const totals = columns.map((column) => column.values.reduce((sum, value) => sum + value, 0));
const tickValues = niceTicks(Math.max(0, ...totals));
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
const baseline = plot.y + plot.height;
const toHeight = (value: number) => (value / scaleMax) * plot.height;
const slotWidth = columns.length > 0 ? plot.width / columns.length : 0;
const barWidth = Math.max(1, slotWidth - BAR_GAP);
const laidOut: StackedColumn[] = columns.map((column, i) => {
const slotX = plot.x + i * slotWidth;
const barX = slotX + (slotWidth - barWidth) / 2;
let top = baseline;
const segments = column.values.map((value) => {
const segmentHeight = toHeight(value);
top -= segmentHeight;
return { x: barX, y: top, width: barWidth, height: segmentHeight };
});
return {
ts: column.ts,
total: totals[i],
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
segments,
};
});
const step = labelStepFor(columns.length, plot.width);
return {
width,
height,
plot,
scaleMax,
columns: laidOut,
yTicks: tickValues.map((value) => ({ value, y: baseline - toHeight(value) })),
xTicks: laidOut
.filter((_, i) => i % step === 0)
.map((column) => ({ ts: column.ts, x: column.slot.x + column.slot.width / 2 })),
};
}
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
const plot = plotRect(width, height);
const maxQueries = buckets.reduce((max, bucket) => Math.max(max, bucket.queries), 0);
const tickValues = niceTicks(maxQueries);
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
@@ -89,10 +173,7 @@ export function layoutTimeseries(buckets: Bucket[], width: number, height: numbe
const yTicks = tickValues.map((value) => ({ value, y: baseline - toHeight(value) }));
const labelStep =
buckets.length > 0 && plot.width > 0
? Math.max(1, Math.ceil((buckets.length * MIN_X_LABEL_PX) / plot.width))
: 1;
const labelStep = labelStepFor(buckets.length, plot.width);
const xTicks = bars
.filter((_, i) => i % labelStep === 0)
.map((bar) => ({ ts: bar.bucket.ts, x: bar.slot.x + bar.slot.width / 2 }));
@@ -0,0 +1,47 @@
import { layoutDonut, type DonutSlice } from "./donutLayout";
function slice(key: string, value: number): DonutSlice {
return { key, label: key, value, color: "#000000" };
}
test("an empty breakdown has no total and no arcs to draw", () => {
expect(layoutDonut([], 100, 20)).toEqual({ size: 100, total: 0, arcs: [] });
});
test("a breakdown of nothing but zeroes is empty, not a division by zero", () => {
const layout = layoutDonut([slice("a", 0), slice("b", 0)], 100, 20);
expect(layout.total).toBe(0);
expect(layout.arcs).toEqual([]);
});
test("zero-valued entries are dropped rather than legended at 0%", () => {
const layout = layoutDonut([slice("a", 3), slice("b", 0), slice("c", 1)], 100, 20);
expect(layout.arcs.map((arc) => arc.slice.key)).toEqual(["a", "c"]);
expect(layout.total).toBe(4);
});
test("shares are of the drawn total and add up to one", () => {
const layout = layoutDonut([slice("a", 3), slice("b", 1)], 100, 20);
expect(layout.arcs.map((arc) => arc.share)).toEqual([0.75, 0.25]);
});
test("slices keep the order they were ranked in, starting at twelve o'clock", () => {
const layout = layoutDonut([slice("a", 1), slice("b", 1)], 100, 20);
expect(layout.arcs[0].d.startsWith("M 50.000 0.000")).toBe(true);
// The second slice begins where the first ended, half a turn round.
expect(layout.arcs[1].d.startsWith("M 50.000 100.000")).toBe(true);
});
test("a slice over half the ring takes the large-arc flag", () => {
const layout = layoutDonut([slice("a", 9), slice("b", 1)], 100, 20);
expect(layout.arcs[0].d).toContain("A 50 50 0 1 1");
expect(layout.arcs[1].d).toContain("A 50 50 0 0 1");
});
test("a single entry is a closed ring, not a zero-length arc that draws nothing", () => {
const layout = layoutDonut([slice("only", 7)], 100, 20);
expect(layout.arcs).toHaveLength(1);
expect(layout.arcs[0].share).toBe(1);
// Two half arcs out and two back: a lone `A` from a point to itself is a no-op.
expect(layout.arcs[0].d.match(/A /g)).toHaveLength(4);
});
@@ -0,0 +1,95 @@
/**
* Annulus geometry for the two breakdown donuts. Pure, so the arithmetic that
* decides whether a slice closes correctly is testable without a DOM.
*/
export interface DonutSlice {
/** Semantic identity: the React key, the colour key and the legend's identity. */
key: string;
label: string;
/** Disambiguates entries whose labels collide — two "Unknown"s, one name on two route kinds. */
secondary?: string;
value: number;
color: string;
}
export interface DonutArc {
slice: DonutSlice;
/** Of the whole, 0 to 1. */
share: number;
d: string;
}
export interface DonutLayout {
size: number;
total: number;
arcs: DonutArc[];
}
const START_ANGLE = -Math.PI / 2;
function point(center: number, radius: number, angle: number): string {
return `${(center + radius * Math.cos(angle)).toFixed(3)} ${(center + radius * Math.sin(angle)).toFixed(3)}`;
}
/**
* A whole-circle slice cannot be drawn as one arc — start and end coincide, and
* the renderer draws nothing at all — so the ring is two half arcs.
*/
function fullRing(center: number, outer: number, inner: number): string {
const top = `${center} ${center - outer}`;
const bottom = `${center} ${center + outer}`;
const innerTop = `${center} ${center - inner}`;
const innerBottom = `${center} ${center + inner}`;
return [
`M ${top}`,
`A ${outer} ${outer} 0 0 1 ${bottom}`,
`A ${outer} ${outer} 0 0 1 ${top}`,
`M ${innerTop}`,
`A ${inner} ${inner} 0 0 0 ${innerBottom}`,
`A ${inner} ${inner} 0 0 0 ${innerTop}`,
"Z",
].join(" ");
}
/**
* Slices in the order given — the caller has already ranked them — starting at
* twelve o'clock and running clockwise. Zero-valued slices are dropped: they
* have no arc to draw, and a legend entry reading 0 is noise.
*/
export function layoutDonut(slices: DonutSlice[], size: number, thickness: number): DonutLayout {
const drawn = slices.filter((slice) => slice.value > 0);
const total = drawn.reduce((sum, slice) => sum + slice.value, 0);
if (total <= 0) return { size, total: 0, arcs: [] };
const center = size / 2;
const outer = center;
const inner = Math.max(0, center - thickness);
if (drawn.length === 1) {
return {
size,
total,
arcs: [{ slice: drawn[0], share: 1, d: fullRing(center, outer, inner) }],
};
}
let angle = START_ANGLE;
const arcs = drawn.map((slice) => {
const share = slice.value / total;
const sweep = share * Math.PI * 2;
const end = angle + sweep;
const large = sweep > Math.PI ? 1 : 0;
const d = [
`M ${point(center, outer, angle)}`,
`A ${outer} ${outer} 0 ${large} 1 ${point(center, outer, end)}`,
`L ${point(center, inner, end)}`,
`A ${inner} ${inner} 0 ${large} 0 ${point(center, inner, angle)}`,
"Z",
].join(" ");
angle = end;
return { slice, share, d };
});
return { size, total, arcs };
}
@@ -0,0 +1,292 @@
/**
* Window coherence across the five Overview requests, migrated from the
* two-request `activityWindow` this replaces. Every behaviour that hook pinned
* is pinned here — the identity, the one retry per mismatch episode, the
* terminal error, the discarded previous-period pair and the stale completion
* that must not speak — now over five endpoints and with the watermark in the
* identity, plus the per-panel isolation the layout added.
*/
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import type { Coverage, Period } from "@/lib/types";
import {
newerWindow,
sameWindow,
useOverviewWindow,
windowIdOf,
OVERVIEW_ENDPOINTS,
type OverviewEndpoint,
} from "./overviewWindow";
const SINCE = Date.UTC(2026, 0, 1, 0, 0) / 1000;
const UNTIL = Date.UTC(2026, 0, 2, 0, 0) / 1000;
const COVERAGE: Coverage = { complete: true, available_since: SINCE };
/** Where each endpoint's body currently ends, and what watermark it admits. */
interface Bounds {
until: number;
availableSince: number;
}
const PATHS: Record<OverviewEndpoint, string> = {
totals: "/api/stats?period=",
timeseries: "/api/stats/timeseries?period=",
clients: "/api/stats/clients?period=",
types: "/api/stats/types?period=",
routes: "/api/stats/routes?period=",
};
let bounds: Record<OverviewEndpoint, Bounds>;
let failing: Set<OverviewEndpoint>;
let calls: Record<OverviewEndpoint, number>;
/** Endpoints that answer for the page's window from their second call onward. */
let catchUp: Set<OverviewEndpoint>;
/** Held to keep one answer in flight while the test moves the page on. */
let hold: { promise: Promise<void>; release: () => void } | null;
function endpointOf(url: string): OverviewEndpoint | null {
// Longest prefix first: `/api/stats?` and `/api/stats/…` share a stem.
for (const endpoint of ["timeseries", "clients", "types", "routes", "totals"] as const) {
if (url.startsWith(PATHS[endpoint])) return endpoint;
}
return null;
}
function body(endpoint: OverviewEndpoint, period: Period): unknown {
const { until, availableSince } = bounds[endpoint];
const shared = { period, since: SINCE, until, coverage: { ...COVERAGE, available_since: availableSince } };
switch (endpoint) {
case "totals":
return { ...shared, queries: 10, blocked: 2, clients: 1, avg_response_time_us: 1000 };
case "timeseries":
return { ...shared, bucket_seconds: 3600, buckets: [] };
case "clients":
return { ...shared, bucket_seconds: 3600, clients: [], other: [] };
case "types":
return { ...shared, types: [] };
case "routes":
return { ...shared, routes: [] };
}
}
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
bounds = {
totals: { until: UNTIL, availableSince: SINCE },
timeseries: { until: UNTIL, availableSince: SINCE },
clients: { until: UNTIL, availableSince: SINCE },
types: { until: UNTIL, availableSince: SINCE },
routes: { until: UNTIL, availableSince: SINCE },
};
failing = new Set();
catchUp = new Set();
hold = null;
calls = { totals: 0, timeseries: 0, clients: 0, types: 0, routes: 0 };
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const endpoint = endpointOf(url);
if (endpoint === null) return json({ error: "not stubbed" }, 404);
calls[endpoint] += 1;
if (failing.has(endpoint)) return json({ error: "endpoint unavailable" }, 400);
if (catchUp.has(endpoint) && calls[endpoint] >= 2)
bounds[endpoint] = { until: UNTIL, availableSince: SINCE };
const period = (new URLSearchParams(url.split("?")[1]).get("period") ?? "24h") as Period;
// Built before the wait, so a held answer carries what its own request
// would have returned rather than what the page has moved on to.
const payload = json(body(endpoint, period));
if (hold !== null && endpoint === "routes" && calls.routes === 2) await hold.promise;
return payload;
}),
);
});
afterEach(() => vi.unstubAllGlobals());
function Probe({ period }: { period: Period }) {
const overview = useOverviewWindow(period);
return (
<ul>
{OVERVIEW_ENDPOINTS.map((endpoint) => {
const panel = overview[endpoint];
const detail =
panel.status === "ready"
? `${panel.data.period}@${panel.data.until}/${panel.data.coverage.available_since}`
: panel.status === "error"
? (panel.error as Error).message
: "";
return <li key={endpoint}>{`${endpoint}:${panel.status}:${detail}`}</li>;
})}
</ul>
);
}
function renderProbe(period: Period = "24h") {
const client = createQueryClient();
const view = render(
<QueryClientProvider client={client}>
<Probe period={period} />
</QueryClientProvider>,
);
return {
rerenderWith: (next: Period) =>
view.rerender(
<QueryClientProvider client={client}>
<Probe period={next} />
</QueryClientProvider>,
),
};
}
function line(endpoint: OverviewEndpoint): string {
const item = screen.getAllByRole("listitem").find((element) => element.textContent?.startsWith(`${endpoint}:`));
if (item === undefined) throw new Error(`no probe line for ${endpoint}`);
return item.textContent ?? "";
}
test("the window identity is the period, both bounds and the watermark together", () => {
const base = { period: "24h" as const, since: SINCE, until: UNTIL, availableSince: SINCE };
expect(sameWindow(base, { ...base })).toBe(true);
expect(sameWindow(base, { ...base, period: "1h" })).toBe(false);
expect(sameWindow(base, { ...base, since: SINCE - 1 })).toBe(false);
expect(sameWindow(base, { ...base, until: UNTIL + 1 })).toBe(false);
// The bounds agree and the answers still describe different windows: a prune
// between the two requests moved what the same span can be answered for.
expect(sameWindow(base, { ...base, availableSince: SINCE + 60 })).toBe(false);
});
test("the newer until wins, and for equal bounds the later watermark does", () => {
const base = { period: "24h" as const, since: SINCE, until: UNTIL, availableSince: SINCE };
expect(newerWindow(base, { ...base, until: UNTIL + 60 }).until).toBe(UNTIL + 60);
expect(newerWindow({ ...base, until: UNTIL + 60 }, base).until).toBe(UNTIL + 60);
expect(newerWindow(base, { ...base, availableSince: SINCE + 60 }).availableSince).toBe(SINCE + 60);
// A newer watermark does not outrank an older window's later bound.
expect(newerWindow({ ...base, until: UNTIL + 60 }, { ...base, availableSince: SINCE + 60 }).until).toBe(UNTIL + 60);
});
test("windowIdOf reads the four fields off any of the five bodies", () => {
expect(windowIdOf({ period: "7d", since: 1, until: 2, coverage: { complete: false, available_since: 3 } })).toEqual(
{
period: "7d",
since: 1,
until: 2,
availableSince: 3,
},
);
});
test("five responses for one window render as five ready panels", async () => {
renderProbe();
await waitFor(() => expect(line("totals")).toContain("ready"));
for (const endpoint of OVERVIEW_ENDPOINTS) {
expect(line(endpoint)).toBe(`${endpoint}:ready:24h@${UNTIL}/${SINCE}`);
}
});
test("one endpoint behind a bucket boundary is refetched once and then agrees", async () => {
// Behind on its first answer, caught up by the time the hook asks again.
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
catchUp.add("routes");
renderProbe();
await waitFor(() => expect(line("routes")).toContain("ready"));
expect(calls.routes).toBe(2);
expect(calls.totals).toBe(1);
});
test("a laggard that stays behind fails its own panel and leaves the rest rendering", async () => {
bounds.types = { until: UNTIL - 3600, availableSince: SINCE };
renderProbe();
await waitFor(() => expect(line("types")).toContain("error"));
expect(line("types")).toContain("different window");
// One retry, not a loop.
expect(calls.types).toBe(2);
for (const endpoint of ["totals", "timeseries", "clients", "routes"] as const) {
expect(line(endpoint)).toContain("ready");
}
});
test("a failed request degrades its own panel; the charts keep the window", async () => {
failing.add("routes");
renderProbe();
await waitFor(() => expect(line("routes")).toContain("error"));
expect(line("routes")).toContain("endpoint unavailable");
expect(line("timeseries")).toContain("ready");
expect(line("totals")).toContain("ready");
});
test("a watermark that advanced mid-page is a mismatch, not a mixed window", async () => {
// Same bounds, later watermark: retention pruned between the two responses.
bounds.clients = { until: UNTIL, availableSince: SINCE + 600 };
renderProbe();
await waitFor(() => expect(line("clients")).toContain(`/${SINCE + 600}`));
// The page adopts the later watermark, so the four older answers are the
// laggards and each gets its one retry rather than rendering beside it.
await waitFor(() => expect(calls.totals).toBe(2));
expect(line("clients")).toContain("ready");
});
test("a retained previous-period body never renders under the new period's label", async () => {
const { rerenderWith } = renderProbe("24h");
await waitFor(() => expect(line("totals")).toBe(`totals:ready:24h@${UNTIL}/${SINCE}`));
rerenderWith("1h");
// Whatever `keepPreviousData` is holding, no panel may claim it answers 1h.
await waitFor(() => expect(line("totals")).toBe(`totals:ready:1h@${UNTIL}/${SINCE}`));
for (const endpoint of OVERVIEW_ENDPOINTS) expect(line(endpoint)).toContain("1h@");
});
test("a period change buys the new window its own retry", async () => {
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
const { rerenderWith } = renderProbe("24h");
await waitFor(() => expect(line("routes")).toContain("error"));
const spent = calls.routes;
rerenderWith("1h");
// The mismatch persists under the new period, and the episode key changed
// with it: the retry the abandoned period spent is not the new one's.
await waitFor(() => expect(calls.routes).toBeGreaterThan(spent));
await waitFor(() => expect(line("routes")).toContain("error"));
});
test("a retry in flight when the period changes cannot spend the window's retry later", async () => {
// The stale completion the tokens exist to orphan: routes lags under 24h, the
// hook issues its one retry, and the reader picks 1h before that retry lands.
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
let release = () => {};
hold = { promise: new Promise<void>((resolve) => (release = resolve)), release: () => release() };
const { rerenderWith } = renderProbe("24h");
await waitFor(() => expect(calls.routes).toBe(2));
bounds.routes = { until: UNTIL, availableSince: SINCE };
rerenderWith("1h");
await waitFor(() => expect(line("routes")).toContain("1h@"));
// The abandoned retry lands now, under a period it was never asked for.
hold.release();
hold = null;
await waitFor(() => expect(line("routes")).toContain("ready"));
// Back to the window it was issued for, still lagging. The stale completion
// must not have marked this episode spent: the panel gets a real retry before
// it is allowed to reach the terminal error.
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
rerenderWith("24h");
// The cached lagging body is there to render immediately, and the panel must
// not state the terminal error off it: that error means "retried and still
// behind", and this visit has not retried anything yet. An abandoned
// completion recording the episode as spent is what would produce it here.
expect(line("routes")).toContain("loading");
await waitFor(() => expect(line("routes")).toContain("different window"));
});
@@ -0,0 +1,249 @@
/**
* One period, five requests, one window.
*
* Totals, the timeline, the per-client series and the two breakdowns are
* separate calls, so a refresh that straddles a bucket boundary — or a retention
* pass that advances the watermark mid-page — can answer them for different
* windows. Rendering them side by side anyway would put a headline count above
* charts of a different span, a mixed page that looks exactly like a real one.
*
* This is **window** coherence, not data-snapshot coherence: matching bounds
* cannot prove a common database state, and live inserts between requests may
* still shift counts slightly between panels. What it does guarantee is that no
* two panels ever describe different spans.
*
* Rendering is per panel. A panel whose request is still in flight shows its own
* loading state and a panel whose request failed shows its own error, while the
* panels that match the window keep rendering — a failed donut never blanks the
* charts.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
import { statsClientsQuery, statsQuery, statsRoutesQuery, statsTypesQuery, timeseriesQuery } from "@/lib/queries";
import type {
Coverage,
Period,
StatsClients,
StatsRoutes,
StatsTimeseries,
StatsTotals,
StatsTypes,
} from "@/lib/types";
export const OVERVIEW_ENDPOINTS = ["totals", "timeseries", "clients", "types", "routes"] as const;
export type OverviewEndpoint = (typeof OVERVIEW_ENDPOINTS)[number];
interface EndpointBodies {
totals: StatsTotals;
timeseries: StatsTimeseries;
clients: StatsClients;
types: StatsTypes;
routes: StatsRoutes;
}
/**
* What makes two responses the same window. `available_since` joins the bounds
* because retention advancing between requests changes what the same `[since,
* until)` can answer for, and mixing a pre-prune answer with a post-prune one is
* the failure the bounds alone would not catch.
*/
export interface WindowId {
period: Period;
since: number;
until: number;
availableSince: number;
}
/** The four fields every window-bounded stats body carries. */
interface Bounded {
period: Period;
since: number;
until: number;
coverage: Coverage;
}
export function windowIdOf(body: Bounded): WindowId {
return {
period: body.period,
since: body.since,
until: body.until,
availableSince: body.coverage.available_since,
};
}
export function sameWindow(a: WindowId, b: WindowId): boolean {
return a.period === b.period && a.since === b.since && a.until === b.until && a.availableSince === b.availableSince;
}
/**
* Which of two candidate windows the page adopts: the one that reaches further
* forward in time, and for identical bounds the one that admits the later
* watermark. Both rules pick the answer a laggard has to catch up to.
*/
export function newerWindow(a: WindowId, b: WindowId): WindowId {
if (b.until !== a.until) return b.until > a.until ? b : a;
return b.availableSince > a.availableSince ? b : a;
}
function keyOf(id: WindowId): string {
return `${id.period}|${id.since}|${id.until}|${id.availableSince}`;
}
export type Panel<T> =
{ status: "loading" } | { status: "error"; error: unknown; retry: () => void } | { status: "ready"; data: T };
export interface OverviewWindow {
/** Null until one response for the selected period has arrived. */
window: WindowId | null;
/** The adopted window's watermark, for the page's single coverage notice. */
coverage: Coverage | null;
totals: Panel<StatsTotals>;
timeseries: Panel<StatsTimeseries>;
clients: Panel<StatsClients>;
types: Panel<StatsTypes>;
routes: Panel<StatsRoutes>;
}
/**
* A laggard that stayed behind after its one retry. Not an `ApiError`: nothing
* failed, the endpoint simply never caught up, and `InlineError` renders the
* message verbatim.
*/
export const MISMATCH = new Error("This panel is for a different window than the rest of the page. Try again.");
export function useOverviewWindow(period: Period): OverviewWindow {
const queries: { [K in OverviewEndpoint]: UseQueryResult<EndpointBodies[K]> } = {
totals: useQuery({ ...statsQuery(period), placeholderData: keepPreviousData }),
timeseries: useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData }),
clients: useQuery({ ...statsClientsQuery(period), placeholderData: keepPreviousData }),
types: useQuery({ ...statsTypesQuery(period), placeholderData: keepPreviousData }),
routes: useQuery({ ...statsRoutesQuery(period), placeholderData: keepPreviousData }),
};
// A `keepPreviousData` placeholder for the period just left is a complete,
// self-consistent body — and still the wrong one to show under the new label,
// so it is neither a candidate for the window nor a member of it.
const answers = new Map<OverviewEndpoint, WindowId>();
for (const endpoint of OVERVIEW_ENDPOINTS) {
const data = queries[endpoint].data;
if (data !== undefined && data.period === period) answers.set(endpoint, windowIdOf(data));
}
let window: WindowId | null = null;
for (const id of answers.values()) window = window === null ? id : newerWindow(window, id);
// The effect below runs on what the responses say, not on how many times they
// arrived: a poll that returns byte-identical data must not restart the retry
// bookkeeping. The refetchers ride a ref for the same reason — TanStack hands
// back a fresh function identity on some renders, and depending on it would
// re-enter the effect with nothing changed.
const answersKey = OVERVIEW_ENDPOINTS.map((endpoint) => {
const id = answers.get(endpoint);
return id === undefined ? "" : keyOf(id);
}).join("~");
const latest = useRef({ answers, refetch: queries });
latest.current = { answers, refetch: queries };
// Which mismatch episode each endpoint has already spent its retry on, keyed
// by endpoint and window identity so a new window buys a new attempt.
const retriedFor = useRef(new Map<OverviewEndpoint, string>());
// Which retry each endpoint is waiting on. Per endpoint, because one shared
// counter would let a second endpoint's retry silence the first's completion;
// bumped on every retry issued, so a completion from a window or a period the
// page has left can neither clear an error the current one reached nor spend
// the current window's one retry.
const tokens = useRef(new Map<OverviewEndpoint, number>());
// State, not a ref: a retry that returns byte-identical data changes nothing
// else a render could see, and the panel still has to reach its error.
const [landedFor, setLandedFor] = useState(new Map<OverviewEndpoint, string>());
// Leaving a period ends every episode it opened. A retry issued for the old
// period can still be in flight, and without this its completion would land
// under the new one holding a token the map still honours: it would record an
// episode as spent, so a return to that window would reach the terminal error
// without the retry that error is supposed to follow. Bumping the tokens
// orphans those answers, and the cleared maps let the new window start clean.
const [lastPeriod, setLastPeriod] = useState(period);
if (lastPeriod !== period) {
setLastPeriod(period);
for (const endpoint of OVERVIEW_ENDPOINTS) {
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
}
retriedFor.current.clear();
setLandedFor(new Map());
}
const windowKey = window === null ? null : keyOf(window);
useEffect(() => {
if (windowKey === null) return;
for (const [endpoint, identity] of latest.current.answers) {
if (keyOf(identity) === windowKey) {
retriedFor.current.delete(endpoint);
continue;
}
const episode = `${endpoint}|${windowKey}`;
if (retriedFor.current.get(endpoint) === episode) continue;
retriedFor.current.set(endpoint, episode);
const token = (tokens.current.get(endpoint) ?? 0) + 1;
tokens.current.set(endpoint, token);
const landed = () => {
if (tokens.current.get(endpoint) !== token) return;
setLandedFor((previous) => new Map(previous).set(endpoint, episode));
};
void latest.current.refetch[endpoint].refetch().then(landed, landed);
}
}, [answersKey, windowKey]);
const retry = useCallback((endpoint: OverviewEndpoint) => {
retriedFor.current.delete(endpoint);
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
setLandedFor((previous) => {
const next = new Map(previous);
next.delete(endpoint);
return next;
});
void latest.current.refetch[endpoint].refetch();
}, []);
function panelOf<K extends OverviewEndpoint>(endpoint: K): Panel<EndpointBodies[K]> {
const query = queries[endpoint];
const onRetry = () => retry(endpoint);
if (query.isError) return { status: "error", error: query.error, retry: onRetry };
const data = query.data;
if (
data !== undefined &&
windowKey !== null &&
data.period === period &&
keyOf(windowIdOf(data)) === windowKey
) {
return { status: "ready", data };
}
if (windowKey !== null && landedFor.get(endpoint) === `${endpoint}|${windowKey}`) {
return { status: "error", error: MISMATCH, retry: onRetry };
}
return { status: "loading" };
}
const panels = {
totals: panelOf("totals"),
timeseries: panelOf("timeseries"),
clients: panelOf("clients"),
types: panelOf("types"),
routes: panelOf("routes"),
};
// The notice describes the window, so any member of it can supply the
// watermark: whichever panel arrived says the same thing about coverage.
let coverage: Coverage | null = null;
for (const endpoint of OVERVIEW_ENDPOINTS) {
const panel = panels[endpoint];
if (panel.status === "ready") {
coverage = panel.data.coverage;
break;
}
}
return { window, coverage, ...panels };
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The period Overview is scoped to, as URL state.
*
* One home for the four values and for the rule that turns whatever the URL
* carried into one of them: the route validates with it and the picker offers
* exactly the same list, so a hand-typed `?period=90d` becomes the default
* instead of reaching the API as a parameter it answers 400 to.
*/
import type { Period } from "@/lib/types";
export const PERIODS = ["1h", "24h", "7d", "30d"] as const satisfies readonly Period[];
export const DEFAULT_PERIOD: Period = "24h";
/**
* Undefined rather than the default for anything that is not one of the four,
* so an absent parameter and a nonsense one both leave a clean URL. The default
* is applied where the period is read, not written back into the address bar.
*/
export function parsePeriod(value: unknown): Period | undefined {
return PERIODS.find((period) => period === value);
}
@@ -0,0 +1,73 @@
import { OTHER_KEY, clientKey, qtypeKey, routeKey, seriesColor } from "./seriesColors";
test("the four source-less route kinds and other are fixed, so they mean one thing everywhere", () => {
expect(seriesColor(routeKey("blocked", null))).toBe("#ef4444");
expect(seriesColor(routeKey("cache", null))).toBe("#059669");
expect(seriesColor(routeKey("local", null))).toBe("#8b5cf6");
expect(seriesColor(routeKey("rejected", null))).toBe("#f59e0b");
expect(seriesColor(OTHER_KEY)).toBe("#71717a");
});
test("the colour of a key depends on the key and on nothing else", () => {
// Rank churn and membership churn at once: the panel a poll later is in the
// opposite order, has gained a client and has lost one. Every entry that
// survived keeps its colour, because nothing here reads the set.
const survivors = [clientKey("192.0.2.30"), clientKey("192.0.2.31"), clientKey("192.0.2.32"), OTHER_KEY];
const before = survivors.map(seriesColor);
const after = [clientKey("192.0.2.10"), ...survivors].reverse().map(seriesColor);
for (const [i, key] of survivors.entries()) {
expect(seriesColor(key)).toBe(before[i]);
expect(after).toContain(before[i]);
}
});
test("a panel of realistic entries gets a spread of hues, not one colour repeated", () => {
// The degenerate implementation this refutes: a dynamic branch that returns
// one constant would satisfy every stability test in this file. It also states
// the real cost of hashing without assignment — eight clients come out in five
// hues here, seven query types in four — which is why the donut strokes its
// arcs and the client chart strokes its segments.
const clients = [
"192.0.2.30",
"192.0.2.31",
"192.0.2.32",
"192.0.2.40",
"10.0.0.5",
"10.0.0.6",
"fd00::1",
"laptop.lan",
];
const types = [1, 28, 65, 12, 16, 33];
const routes = ["https://dns.example/dns-query", "https://dns2.example/dns-query", "lan"];
const spreadOf = (keys: string[]) => new Set(keys.map(seriesColor)).size;
expect(spreadOf(clients.map(clientKey))).toBeGreaterThan(1);
expect(spreadOf([...types.map(qtypeKey), qtypeKey(null)])).toBeGreaterThan(1);
expect(spreadOf(routes.map((source) => routeKey("upstream", source)))).toBeGreaterThan(1);
// Half the panel distinct at worst, which is what makes the legend readable
// rather than a list of identical swatches.
expect(spreadOf(clients.map(clientKey))).toBeGreaterThanOrEqual(clients.length / 2);
});
test("a dynamic entry never takes a fixed entry's colour", () => {
// The bug this rules out: a nameless upstream row coming out the same red as
// the Blocked slice beside it in the same ring.
const fixedColors = new Set(["#ef4444", "#059669", "#8b5cf6", "#f59e0b", "#71717a"]);
const keys = [routeKey("upstream", null), routeKey("forward_zone", "lan"), qtypeKey(28), qtypeKey(null)];
for (const key of keys) expect(fixedColors.has(seriesColor(key))).toBe(false);
});
test("the same name under two route kinds is two identities", () => {
expect(routeKey("upstream", "lan")).not.toBe(routeKey("forward_zone", "lan"));
});
test("two upstreams are two identities: the pair, not the route kind, is the key", () => {
expect(routeKey("upstream", "https://dns.example/dns-query")).not.toBe(
routeKey("upstream", "https://dns2.example/dns-query"),
);
});
test("a null qtype is its own entry rather than folded into a real one", () => {
expect(qtypeKey(null)).not.toBe(qtypeKey(1));
expect(seriesColor(qtypeKey(null))).toBe(seriesColor(qtypeKey(null)));
});
@@ -0,0 +1,84 @@
/**
* A colour per thing, not per position.
*
* Every series and slice on Overview is ranked by count, and a rank that changes
* between two thirty-second polls would recolour the whole panel if colour came
* from the ordinal. So colour keys on the entry's semantic identity: the qtype
* value, the client string, or — for routes — the full `(route, source)` pair,
* because keying on the route kind alone would paint two adjacent upstream
* slices the same and merge them into one shape.
*
* The four source-less route kinds and the "other" bucket are fixed rather than
* hashed: they mean the same thing on every install, and Blocked and Cache
* already have colours on the query-volume timeline.
*/
import type { RouteKind } from "@/lib/types";
/**
* The dynamic hues, validated for CVD separation and 3:1 contrast against both
* surfaces; the same hex in light and dark, as the timeline's series are. The
* five fixed colours below are deliberately not in here: a nameless upstream row
* must not come out the same red as Blocked in the ring beside it.
*/
const PALETTE = ["#3b82f6", "#ec4899", "#14b8a6", "#f97316", "#6366f1", "#84cc16", "#06b6d4", "#a855f7"] as const;
const FIXED: Record<string, string> = {
"route:blocked": "#ef4444",
"route:cache": "#059669",
"route:local": "#8b5cf6",
"route:rejected": "#f59e0b",
other: "#71717a",
};
/** The identity of everything outside the top eight clients. */
export const OTHER_KEY = "other";
export function qtypeKey(qtype: number | null): string {
return qtype === null ? "qtype:none" : `qtype:${qtype}`;
}
export function clientKey(client: string): string {
return `client:${client}`;
}
/**
* Upstream and forward-zone rows are identified by their source as well as their
* kind; the other four kinds have no source and collapse to the kind alone, so
* they land on their fixed colour.
*/
export function routeKey(route: RouteKind, source: string | null): string {
return source === null ? `route:${route}` : `route:${route}:${source}`;
}
/** FNV-1a, 32-bit: stable across reloads and across browsers, which is the whole point. */
function hash(key: string): number {
let value = 0x811c9dc5;
for (let i = 0; i < key.length; i += 1) {
value ^= key.charCodeAt(i);
value = Math.imul(value, 0x01000193);
}
return value >>> 0;
}
/**
* The colour of one key, and of nothing else.
*
* This is a pure function of the identity: no panel, no key set, no rank. That
* is the property the page needs, because the panels churn — a client enters the
* top eight and another leaves it every few polls — and an assignment that read
* the whole set would repaint entries that did not change at all.
*
* The cost is that a hash is not injective: two entries of one panel can come
* out the same hue. That is a real cost and it is the smaller one. Resolving it
* by probing would mean the entries that lost a slot depend on which entries
* were present, which is the churn this exists to prevent — and eight hues
* cannot colour nine things distinctly in any case. The failure a shared hue
* would cause instead, two neighbouring slices merging into one shape, is
* prevented where it happens: the donut strokes every arc and the client chart
* strokes every segment in the surface colour, so equal hues still read as two.
* The legend and the hidden table name every entry either way.
*/
export function seriesColor(key: string): string {
return FIXED[key] ?? PALETTE[hash(key) % PALETTE.length];
}
@@ -0,0 +1,287 @@
/**
* The Pause control, migrated from the header PauseWidget it replaces. Every
* behaviour that widget pinned is pinned here, now driven by `Health.protection`
* rather than by a second poll of `/api/pause`.
*/
import { act } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import PauseControl from "@/features/pause/PauseControl";
import { formatClock } from "@/lib/format";
import { createQueryClient } from "@/lib/queryClient";
import { health } from "@/lib/healthFixture";
import { queryKeys } from "@/lib/queries";
import type { Health, PausePost, PauseState } from "@/lib/types";
let protection: Health["protection"];
let postBodies: PausePost[];
let postFailure: (() => Response) | null;
let healthFails: boolean;
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
protection = { state: "active", until: null };
postBodies = [];
postFailure = null;
healthFails = false;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/health") {
if (healthFails) {
return new Response(JSON.stringify({ error: "nope" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
return jsonResponse(health({ protection }));
}
if (url === "/api/pause" && init?.method === "POST") {
const body = JSON.parse(String(init.body)) as PausePost;
postBodies.push(body);
if (postFailure !== null) return postFailure();
protection = body.paused
? {
state: "paused",
until:
body.duration_seconds == null
? null
: Math.floor(Date.now() / 1000) + body.duration_seconds,
}
: { state: "active", until: null };
const echo: PauseState = { paused: body.paused, until: protection.until };
return jsonResponse(echo);
}
return jsonResponse({ error: "not stubbed" });
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
function renderControl(client?: QueryClient) {
render(
<QueryClientProvider client={client ?? createQueryClient()}>
<PauseControl />
</QueryClientProvider>,
);
}
async function findPauseTrigger(): Promise<HTMLButtonElement> {
await waitFor(() => {
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
expect(button.disabled).toBe(false);
});
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
}
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
renderControl();
const trigger = await findPauseTrigger();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
expect(screen.getByRole("button", { name: label })).toBeTruthy();
}
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
await screen.findByRole("button", { name: "Resume" });
});
test("indefinite pause sends no duration_seconds", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
await screen.findByRole("button", { name: "Resume" });
});
test("resume posts paused false and returns to the Pause button", async () => {
protection = { state: "paused", until: null };
renderControl();
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
await screen.findByRole("button", { name: "Pause" });
});
test("a timed pause says until when, beside the control that would end it", () => {
const nowSec = Math.floor(Date.now() / 1000);
const client = createQueryClient();
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: nowSec + 90 } }));
renderControl(client);
// The same clock format the Diagnostics health strip writes, off the same
// reading, so the two cannot say different things about one pause.
expect(screen.getByText(`Paused until ${formatClock(nowSec + 90)}`)).toBeTruthy();
expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy();
});
test("a pause with no end says so without inventing a time", () => {
const client = createQueryClient();
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: null } }));
renderControl(client);
expect(screen.getByText("Paused")).toBeTruthy();
expect(screen.queryByText(/until/)).toBeNull();
});
test("an active resolver states nothing: the button already says Pause", async () => {
renderControl();
expect(await findPauseTrigger()).toBeTruthy();
expect(screen.queryByText(/^Paused/)).toBeNull();
});
test("escape closes the duration menu", async () => {
renderControl();
const trigger = await findPauseTrigger();
fireEvent.click(trigger);
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
});
test("failed pause with 429 shows a ticking retry countdown", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
postFailure = () =>
new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "30" },
});
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
act(() => {
vi.advanceTimersByTime(1000);
});
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
});
test("failed pause with 503 shows the degraded message", async () => {
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
});
test("a successful pause clears the previous mutation error", async () => {
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("alert");
postFailure = null;
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("button", { name: "Resume" });
expect(screen.queryByRole("alert")).toBeNull();
});
test("no control at all while protection is unavailable", async () => {
protection = { state: "unavailable", until: null };
const client = createQueryClient();
renderControl(client);
await waitFor(() => expect(client.getQueryData(queryKeys.health)).toBeDefined());
expect(screen.queryByRole("button")).toBeNull();
});
test("a failed poll after a good one withdraws the control rather than acting on a stale state", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
await vi.waitFor(() =>
expect((screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement).disabled).toBe(false),
);
healthFails = true;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
vi.useRealTimers();
});
test("a menu left open when the control withdraws does not come back open", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
protection = { state: "unavailable", until: null };
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
protection = { state: "active", until: null };
await vi.advanceTimersByTimeAsync(11_000);
// Protection is back, and so is the trigger — but the menu is a thing the
// reader opened, and nobody opened this one.
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers();
});
test("a menu open when someone else pauses does not reopen when that pause ends", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
// Filtering is paused from somewhere else, and this browser learns it from
// the poll. The Resume rendering has no menu.
protection = { state: "paused", until: null };
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy());
protection = { state: "active", until: null };
await vi.advanceTimersByTimeAsync(11_000);
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers();
});
test("protection unknown offers no control at all, by the same rule as unavailable", () => {
// Health has not answered. Which of Pause and Resume applies is exactly what
// it has not said, so the control names neither.
renderControl();
expect(screen.queryByRole("button")).toBeNull();
});
+181
View File
@@ -0,0 +1,181 @@
/**
* The Pause/Resume control, in the two places a pause is a valid answer to what
* the reader is looking at: the foot of the sidebar, where it belongs to the
* resolver rather than to any page, and beside the detail of a query that was
* blocked.
*
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
* the Diagnostics health strip, and it renders nothing at all while protection
* is unavailable or unknown — pausing a resolver that has no filter snapshot
* would change nothing an operator could observe, and a state health has not
* confirmed does not name an action either.
*
* A pause says so, wherever the control is. "Resume" alone names an action
* without stating the state it would end, and with the header indicator and the
* Overview status row both gone the sidebar is the only place most pages can
* carry that fact at all: a paused resolver would otherwise leave no trace
* outside the Diagnostics page. The line and the health strip cannot disagree —
* one `protection` reading, one clock format, and the expiry refetch in
* `useProtection` retires both at the same moment. An active resolver gets no
* line: the button says Pause, which is the whole message.
*/
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatClock } from "@/lib/format";
import { pauseMutation } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { useProtection } from "./protection";
const DURATIONS = [
{ label: "60 seconds", seconds: 60 },
{ label: "5 minutes", seconds: 300 },
{ label: "30 minutes", seconds: 1800 },
{ label: "Indefinitely", seconds: null },
] as const;
const styles = stylex.create({
/**
* Disabled text darkens in light scheme and lightens in dark, the opposite
* direction from `textMuted`, so the token cannot express it.
*/
trigger: {
color: {
default: null,
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
},
row: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "0.25rem",
},
/** Text, never a colour or an icon alone: this is the state, spelled out. */
state: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
left: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export default function PauseControl() {
const queryClient = useQueryClient();
const protection = useProtection();
const mutation = useMutation(pauseMutation(queryClient));
const [menuOpen, setMenuOpen] = useState(false);
const paused = protection.state === "paused";
const { reset } = mutation;
useEffect(() => reset(), [paused, reset]);
// Nothing to offer, by the same rule in both cases: unavailable has no action
// worth taking, and unknown has no way to tell which of the two it would be.
// A disabled "Pause" beside a reading that says "Paused until 14:05" names
// the wrong action, which is the contradiction this control exists to end.
const actionable = protection.state === "active" || protection.state === "paused";
// Leaving `active` unmounts the menu but not the state that opened it, and
// the menu belongs to the active rendering alone — a pause someone else
// started, seen through the poll, takes it away exactly as a withdrawal does.
// Closing on the way out rather than on the way back means the trigger can
// only ever come back shut, however long it was gone.
useEffect(() => {
if (protection.state !== "active") setMenuOpen(false);
}, [protection.state]);
if (!actionable) return null;
if (paused) {
const until = protection.state === "paused" ? protection.until : null;
return (
<div {...stylex.props(styles.row)}>
<p {...stylex.props(styles.state)}>
{until === null ? "Paused" : `Paused until ${formatClock(until)}`}
</p>
<button
type="button"
onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Resume
</button>
<InlineError error={mutation.error} />
</div>
);
}
return (
<div
{...stylex.props(styles.anchor)}
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
>
<button
type="button"
aria-expanded={menuOpen}
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Pause
</button>
{menuOpen && (
<div id="pause-menu" {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<button
key={label}
type="button"
onClick={() => {
setMenuOpen(false);
mutation.mutate(
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
>
{label}
</button>
))}
</div>
)}
<InlineError error={mutation.error} />
</div>
);
}
@@ -1,194 +0,0 @@
import { act } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import PauseWidget, { formatRemaining } from "@/features/pause/PauseWidget";
import { createQueryClient } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queries";
import type { PausePost, PauseState } from "@/lib/types";
let getState: PauseState;
let postBodies: PausePost[];
let postResponse: (body: PausePost) => PauseState;
let postFailure: (() => Response) | null;
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
postBodies = [];
postFailure = null;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url !== "/api/pause") return jsonResponse({ error: "not stubbed" });
if (init?.method === "POST") {
const body = JSON.parse(String(init.body)) as PausePost;
postBodies.push(body);
if (postFailure !== null) return postFailure();
return jsonResponse(postResponse(body));
}
return jsonResponse(getState);
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
function renderWidget(client?: QueryClient) {
render(
<QueryClientProvider client={client ?? createQueryClient()}>
<PauseWidget />
</QueryClientProvider>,
);
}
async function findPauseTrigger(): Promise<HTMLButtonElement> {
await waitFor(() => {
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
expect(button.disabled).toBe(false);
});
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
}
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
getState = { paused: false, until: null };
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
renderWidget();
const trigger = await findPauseTrigger();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
expect(screen.getByRole("button", { name: label })).toBeTruthy();
}
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
await screen.findByRole("button", { name: "Resume" });
expect(screen.getByText(/^Paused \d+:\d{2}$/)).toBeTruthy();
});
test("indefinite pause sends no duration_seconds and renders without a countdown", async () => {
getState = { paused: false, until: null };
postResponse = () => ({ paused: true, until: null });
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
await screen.findByRole("button", { name: "Resume" });
expect(screen.getByText("Paused")).toBeTruthy();
});
test("resume posts paused false and returns to the Pause button", async () => {
getState = { paused: true, until: null };
postResponse = () => ({ paused: false, until: null });
renderWidget();
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
await screen.findByRole("button", { name: "Pause" });
});
test("timed pause counts down live", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const nowSec = Math.floor(Date.now() / 1000);
const client = createQueryClient();
client.setQueryData(queryKeys.pause, { paused: true, until: nowSec + 90 });
renderWidget(client);
expect(screen.getByText("Paused 1:30")).toBeTruthy();
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByText("Paused 1:28")).toBeTruthy();
});
test("escape closes the duration menu", async () => {
getState = { paused: false, until: null };
postResponse = () => getState;
renderWidget();
const trigger = await findPauseTrigger();
fireEvent.click(trigger);
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
});
test("failed pause with 429 shows a ticking retry countdown", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
getState = { paused: false, until: null };
postFailure = () =>
new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "30" },
});
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
act(() => {
vi.advanceTimersByTime(1000);
});
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
});
test("failed pause with 503 shows the degraded message", async () => {
getState = { paused: false, until: null };
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
});
test("a successful pause clears the previous mutation error", async () => {
getState = { paused: false, until: null };
postFailure = () =>
new Response(JSON.stringify({ error: "unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
});
renderWidget();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("alert");
postFailure = null;
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("button", { name: "Resume" });
expect(screen.queryByRole("alert")).toBeNull();
});
test("formatRemaining renders m:ss and h:mm:ss and clamps at zero", () => {
expect(formatRemaining(0)).toBe("0:00");
expect(formatRemaining(-5)).toBe("0:00");
expect(formatRemaining(59)).toBe("0:59");
expect(formatRemaining(90)).toBe("1:30");
expect(formatRemaining(3661)).toBe("1:01:01");
});
-187
View File
@@ -1,187 +0,0 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DURATIONS = [
{ label: "60 seconds", seconds: 60 },
{ label: "5 minutes", seconds: 300 },
{ label: "30 minutes", seconds: 1800 },
{ label: "Indefinitely", seconds: null },
] as const;
const styles = stylex.create({
/**
* Disabled text darkens in light scheme and lightens in dark, the opposite
* direction from `textMuted`, so the token cannot express it.
*/
trigger: {
color: {
default: null,
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
},
pausedRow: {
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
},
pausedControls: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
/**
* Amber as standalone text on the app ground, not inside a warning banner, so
* the `warn*` tokens — tuned against `warnSurface` — do not apply here.
*/
pausedLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(55.5% 0.163 48.998)",
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
},
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
right: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export function formatRemaining(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds);
const hours = Math.floor(clamped / 3600);
const minutes = Math.floor((clamped % 3600) / 60);
const seconds = clamped % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function useNowSeconds(active: boolean): number {
const [now, setNow] = useState(nowSeconds);
useEffect(() => {
if (!active) return;
setNow(nowSeconds());
const id = setInterval(() => setNow(nowSeconds()), 1000);
return () => clearInterval(id);
}, [active]);
return now;
}
export default function PauseWidget() {
const queryClient = useQueryClient();
const { data } = useQuery({
...pauseQuery(),
refetchInterval: (query) => (query.state.data?.paused === true ? 5000 : false),
});
const mutation = useMutation(pauseMutation(queryClient));
const [menuOpen, setMenuOpen] = useState(false);
const now = useNowSeconds(data?.paused === true && data.until !== null);
const paused = data?.paused === true;
const { reset } = mutation;
useEffect(() => reset(), [paused, reset]);
if (data === undefined) {
return (
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
Pause
</button>
);
}
if (data.paused) {
return (
<div {...stylex.props(styles.pausedRow)}>
<div {...stylex.props(styles.pausedControls)}>
<span {...stylex.props(styles.pausedLabel)}>
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
</span>
<button
type="button"
onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Resume
</button>
</div>
<InlineError error={mutation.error} />
</div>
);
}
return (
<div
{...stylex.props(styles.anchor)}
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
>
<button
type="button"
aria-expanded={menuOpen}
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Pause
</button>
{menuOpen && (
<div id="pause-menu" {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<button
key={label}
type="button"
onClick={() => {
setMenuOpen(false);
mutation.mutate(
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
>
{label}
</button>
))}
</div>
)}
<InlineError error={mutation.error} />
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Protection, as the interface reads it: one view model derived from
* `Health.protection`, shared by the sidebar Pause control, the related action
* on a blocked query's detail, and the Diagnostics health strip.
*
* There is deliberately no second source. `GET /api/pause` answers the same
* question, but two polled copies of one fact can disagree, and the row that
* says "Paused" beside a button that says "Pause" is exactly the contradiction
* this milestone set out to remove.
*/
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { healthQuery, queryKeys } from "@/lib/queries";
import type { Health } from "@/lib/types";
/**
* `unknown` is its own state, never folded into `active`: health has not
* answered yet, or the last poll failed, and claiming filtering is in force on
* no evidence is the one reading that could get a household bitten.
*/
export type ProtectionView =
{ state: "unknown" } | { state: "active" } | { state: "paused"; until: number | null } | { state: "unavailable" };
export function protectionViewOf(protection: Health["protection"] | undefined): ProtectionView {
if (protection === undefined) return { state: "unknown" };
if (protection.state === "unavailable") return { state: "unavailable" };
if (protection.state === "paused") return { state: "paused", until: protection.until };
return { state: "active" };
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
/**
* Protection now, with the expiry of a timed pause scheduled.
*
* Health polls every ten seconds, so without the timer a pause that ended
* three seconds ago still reads "Paused until 14:05" — a stale claim about the
* one fact the indicator exists to state. The timeout fires a second past
* `until` so the server's own expiry rule, not the browser's clock, decides.
*/
export function useProtection(): ProtectionView {
const queryClient = useQueryClient();
const { data, isError } = useQuery(healthQuery());
// A failed poll leaves the last body in the cache, and that body is a
// reading, not the current state: the query this view answers is "is
// filtering in force right now", and a stale yes is the same lie as an
// invented one. The Diagnostics health strip may still render the cached
// conditions, because it marks them stale in the same breath; this view has
// no such caption, so a failure is `unknown`.
const view = isError ? ({ state: "unknown" } as const) : protectionViewOf(data?.protection);
const until = view.state === "paused" ? view.until : null;
useEffect(() => {
if (until === null) return;
const delay = Math.max(0, until - nowSeconds() + 1) * 1000;
const timer = setTimeout(() => void queryClient.invalidateQueries({ queryKey: queryKeys.health }), delay);
return () => clearTimeout(timer);
}, [until, queryClient]);
return view;
}
@@ -99,7 +99,7 @@ test("renders the upstream table and the add form", async () => {
expect(disabledToggle.checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
expect(screen.getByText(/reflects the running pool/)).toBeTruthy();
expect(screen.getByText(/counts the running pool/)).toBeTruthy();
});
test("adding an upstream posts every field and raises the restart banner", async () => {
@@ -97,8 +97,8 @@ export default function UpstreamsPage() {
<section>
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
<p {...stylex.props(styles.intro)}>
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
health table on the Dashboard reflects the running pool, not this list.
The pool builds its clients at startup, so an edit here takes effect at the next restart. The Upstreams
row on Overview counts the running pool, not this list.
</p>
{upstreams.length === 0 ? (
+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;
+63 -20
View File
@@ -5,6 +5,7 @@ import {
createRoute,
createRouter,
lazyRouteComponent,
redirect,
useRouter,
type ErrorComponentProps,
type RouterHistory,
@@ -34,11 +35,15 @@ import {
queryDetailQuery,
rulesQuery,
settingsQuery,
statsClientsQuery,
statsQuery,
statsRoutesQuery,
statsTypesQuery,
timeseriesQuery,
upstreamHealthQuery,
upstreamsQuery,
} from "@/lib/queries";
import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period";
import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -130,20 +135,51 @@ const shellRoute = createRoute({
component: AppShell,
});
const dashboardRoute = createRoute({
/**
* The landing default, not a compatibility alias: Overview is where the app
* opens, and `/` is spelled out rather than left as a second name for it.
*/
const indexRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/",
// allSettled, not all: DashboardPage reads these with useQuery so each widget
// can render its own error. A rejecting loader would replace the whole page
// with RouteError and take the three healthy widgets down with the failed one.
loader: ({ context }) =>
Promise.allSettled([
context.queryClient.ensureQueryData(statsQuery("24h")),
context.queryClient.ensureQueryData(timeseriesQuery("24h")),
context.queryClient.ensureQueryData(healthQuery()),
context.queryClient.ensureQueryData(upstreamHealthQuery()),
]),
component: lazyRouteComponent(() => import("@/features/dashboard/DashboardPage")),
beforeLoad: () => {
throw redirect({ to: "/overview" });
},
});
/**
* Overview. The period is the whole of its applied state, so a view of the page
* is a link: a hand-typed or stale value falls back to the default rather than
* reaching the API as a parameter it answers 400 to.
*/
const overviewRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/overview",
validateSearch: (search: Record<string, unknown>): { period?: Period } => ({
period: parsePeriod(search["period"]),
}),
loaderDeps: ({ search }): { period: Period } => ({ period: search.period ?? DEFAULT_PERIOD }),
/**
* Started here, awaited nowhere. Every panel reads these with `useQuery` and
* owns its own loading and error surface, so awaiting would trade that whole
* contract for one blocking navigation: the page would sit on the slowest of
* five requests and then appear complete, instead of the four that answered
* rendering beside the one still in flight. The rejections are caught only to
* keep them from going unhandled; the panels state them.
*/
loader: ({ context, deps }) => {
const start = (promise: Promise<unknown>) => void promise.catch(() => {});
start(context.queryClient.ensureQueryData(healthQuery()));
start(context.queryClient.ensureQueryData(statsQuery(deps.period)));
start(context.queryClient.ensureQueryData(timeseriesQuery(deps.period)));
start(context.queryClient.ensureQueryData(statsClientsQuery(deps.period)));
// The registered names the client chart labels its series with. Started here
// so the lookup is not a second round trip after the page chunk lands.
start(context.queryClient.ensureQueryData(clientsQuery()));
start(context.queryClient.ensureQueryData(statsTypesQuery(deps.period)));
start(context.queryClient.ensureQueryData(statsRoutesQuery(deps.period)));
},
component: lazyRouteComponent(() => import("@/features/overview/OverviewPage")),
});
/**
@@ -301,14 +337,20 @@ const diagnosticsRoute = createRoute({
since: search.since,
until: search.until,
}),
// allSettled: the two sections render their own state, and the resolved
// history failing must not replace the active list with the error page.
/**
* Started, not awaited, as Overview's is: the strip and the two lists each
* render their own loading and error state, and the health strip's whole
* contract begins with a visible loading state it would never reach if the
* route held the page back until the reading arrived.
*/
loader: ({ context, deps }) => {
const base = diagnosticsFilterOf(deps);
return Promise.allSettled([
context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "active" })),
context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "resolved" })),
]);
void context.queryClient.ensureQueryData(healthQuery()).catch(() => {});
for (const state of ["active", "resolved"] as const) {
void context.queryClient
.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state }))
.catch(() => {});
}
},
component: lazyRouteComponent(() => import("@/features/diagnostics/DiagnosticsPage")),
});
@@ -334,7 +376,8 @@ const settingsRoute = createRoute({
const routeTree = rootRoute.addChildren([
loginRoute,
shellRoute.addChildren([
dashboardRoute,
indexRoute,
overviewRoute,
activityRoute,
activityDetailRoute,
activityTestRoute,
+160 -53
View File
@@ -1,12 +1,15 @@
import { fireEvent, render, screen } from "@testing-library/react";
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, resetAuthProbeForTests } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { formatClock } from "@/lib/format";
import { health } from "@/lib/healthFixture";
import type { Health } from "@/lib/types";
const NAV_LABELS = [
"Dashboard",
"Overview",
"Activity",
"Clients",
"Groups",
@@ -25,7 +28,6 @@ const RESPONSES: Record<string, unknown> = {
until: 86400,
queries: 0,
blocked: 0,
cached: 0,
clients: 0,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
@@ -38,27 +40,50 @@ const RESPONSES: Record<string, unknown> = {
buckets: [],
coverage: { complete: true, available_since: 0 },
},
"/api/health": {
status: "ok",
disk: { state: "ok", free_bytes: 0, db_bytes: 0, log_bytes: 0, sample_failures: 0 },
upstreams: { available: 1, total: 1 },
queries_dropped: 0,
writer_failed: false,
refreshes_gated: 0,
snapshot_generation: null,
diagnostics: { state: "recording", active_warnings: 0, active_errors: 0 },
"/api/stats/clients?period=24h": {
period: "24h",
since: 0,
until: 86400,
bucket_seconds: 1800,
clients: [],
other: [],
coverage: { complete: true, available_since: 0 },
},
"/api/upstream/health": { upstreams: [], available: 1, total: 1 },
"/api/stats/types?period=24h": {
period: "24h",
since: 0,
until: 86400,
types: [],
coverage: { complete: true, available_since: 0 },
},
"/api/stats/routes?period=24h": {
period: "24h",
since: 0,
until: 86400,
routes: [],
coverage: { complete: true, available_since: 0 },
},
"/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } },
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */
let healthBody: Health | null;
function stubFetch(extra: (url: string) => Response | null = () => null) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const override = extra(url);
if (override !== null) return override;
if (url === "/api/health") {
const failed = healthBody === null;
return new Response(JSON.stringify(failed ? { error: "health unavailable" } : healthBody), {
status: failed ? 503 : 200,
headers: { "content-type": "application/json" },
});
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
@@ -67,13 +92,9 @@ beforeEach(() => {
});
}),
);
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("shell renders the dashboard route with all nav links", async () => {
function renderShell() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
@@ -83,8 +104,30 @@ test("shell renders the dashboard route with all nav links", async () => {
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
await screen.findByRole("heading", { name: "Dashboard" });
function diagnosticsBadgeText(): string | null {
const link = screen.getAllByRole("link", { name: /^Diagnostics/ })[0];
const badge = link.querySelector("[aria-label]");
return badge === null ? null : (badge.textContent ?? "");
}
beforeEach(() => {
sessionStorage.clear();
resetAuthProbeForTests();
healthBody = health();
stubFetch();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("shell renders the overview route with all nav links", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav).toBeTruthy();
@@ -94,41 +137,105 @@ test("shell renders the dashboard route with all nav links", async () => {
});
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/auth/login")
return new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
});
if (url === "/api/auth/logout")
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "retry-after": "7" },
});
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
stubFetch((url) => {
if (url === "/api/auth/login")
return new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}),
);
if (url === "/api/auth/logout")
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "retry-after": "7" },
});
return null;
});
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
renderShell();
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
await screen.findByText("Rate limited. Try again in 7s.");
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
});
test("the header carries no protection display at all any more", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
for (const gone of [/^Protection/, /^Paused/]) {
expect(screen.queryByRole("link", { name: gone })).toBeNull();
}
});
test("Pause sits at the foot of the sidebar, above the version label", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const pause = await waitFor(() => within(aside).getByRole("button", { name: "Pause" }));
const version = within(aside).getByText(/^nxdns v/);
// Node order, not styling: the control precedes the version footer.
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("the mobile drawer carries the same control, not a header one it lost", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(1));
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
// Both renderings are mounted; the viewport decides which is painted.
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(2));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
const pause = within(drawer).getByRole("button", { name: "Pause" });
const version = within(drawer).getByText(/^nxdns v/);
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
// The trace a pause leaves on every page. With the header indicator and the
// Overview status row both gone, a reader who is not on Diagnostics has only
// this line to tell them filtering is off.
const until = Math.floor(Date.now() / 1000) + 90;
healthBody = health({ protection: { state: "paused", until } });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
await waitFor(() => expect(within(aside).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
await waitFor(() => expect(within(drawer).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
});
test("nothing open and a healthy rollup leaves the Diagnostics item unbadged", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(document.querySelector("aside")?.textContent).toContain("Diagnostics"));
expect(diagnosticsBadgeText()).toBeNull();
});
test("open episodes are counted on the nav item", async () => {
healthBody = health({ diagnostics: { state: "recording", active_warnings: 1, active_errors: 2 } });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("3"));
expect(screen.getAllByLabelText("3 active diagnostic events").length).toBeGreaterThan(0);
});
test("a degraded rollup with nothing open is still marked, and a failed poll too", async () => {
healthBody = health({ status: "degraded" });
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
expect(screen.getAllByLabelText("Health degraded").length).toBeGreaterThan(0);
});
test("a health poll that failed is marked unknown rather than left looking healthy", async () => {
healthBody = null;
renderShell();
await screen.findByRole("heading", { name: "Overview" });
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
expect(screen.getAllByLabelText("Health unavailable").length).toBeGreaterThan(0);
});
+59 -9
View File
@@ -4,8 +4,9 @@ import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError";
import { versionQuery } from "@/lib/queries";
import PauseWidget from "../features/pause/PauseWidget";
import { healthQuery, versionQuery } from "@/lib/queries";
import PauseControl from "@/features/pause/PauseControl";
import { diagnosticsBadge } from "./diagnosticsBadge";
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
import RestartBanner from "../features/settings/RestartBanner";
import { styles as shared } from "@/ui/styles";
@@ -16,7 +17,7 @@ const WIDE = "@media (min-width: 768px)";
const DARK = "@media (prefers-color-scheme: dark)";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard" },
{ to: "/overview", label: "Overview" },
{ to: "/activity", label: "Activity" },
{ to: "/clients", label: "Clients" },
{ to: "/groups", label: "Groups" },
@@ -35,12 +36,40 @@ const styles = stylex.create({
gap: "0.25rem",
},
navLink: {
display: "block",
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderRadius: "0.25rem",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textDecorationLine: "none",
},
navLabel: {
flex: 1,
},
/**
* Neutral chrome: the mark is the message, and a coloured pill here would be
* the page's loudest element on every route. Text and shape carry it.
*/
badge: {
minWidth: "1.25rem",
borderRadius: "0.625rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceHover,
paddingInline: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1.125rem",
fontWeight: 500,
textAlign: "center",
color: colors.textSecondary,
},
/** The control sits with the footer, not in the scrolling nav list above it. */
sidebarFooter: {
paddingInline: "1rem",
paddingTop: "0.75rem",
},
/** The current page reads as a filled chip, heavier than the hover fill. */
navActive: {
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
@@ -135,6 +164,8 @@ const styles = stylex.create({
});
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const health = useQuery(healthQuery());
const badge = diagnosticsBadge(health.data, health.isError);
return (
<ul {...stylex.props(styles.navList)}>
{NAV_ITEMS.map((item) => (
@@ -142,7 +173,6 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
<Link
to={item.to}
onClick={onNavigate}
activeOptions={{ exact: item.to === "/" }}
activeProps={{
"aria-current": "page",
className: stylex.props(styles.navActive).className,
@@ -150,7 +180,12 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
inactiveProps={{ className: stylex.props(styles.navIdle).className }}
{...stylex.props(styles.navLink, shared.focusRing)}
>
{item.label}
<span {...stylex.props(styles.navLabel)}>{item.label}</span>
{item.to === "/diagnostics" && badge !== null && (
<span aria-label={badge.label} {...stylex.props(styles.badge)}>
{badge.text}
</span>
)}
</Link>
</li>
))}
@@ -158,6 +193,22 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
);
}
/**
* The sidebar's foot, in both renderings. Pause is a runtime action on the whole
* resolver rather than on the page in front of the reader, which is why it sits
* with the version label instead of in the header of every route.
*/
function SidebarFooter() {
return (
<>
<div {...stylex.props(styles.sidebarFooter)}>
<PauseControl />
</div>
<VersionFooter />
</>
);
}
function VersionFooter() {
const { data } = useQuery(versionQuery());
return (
@@ -201,7 +252,7 @@ export default function AppShell() {
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
<NavLinks />
</nav>
<VersionFooter />
<SidebarFooter />
</aside>
<div {...stylex.props(styles.column)}>
<header {...stylex.props(styles.header)}>
@@ -216,7 +267,6 @@ export default function AppShell() {
</button>
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div {...stylex.props(styles.headerRight)}>
<PauseWidget />
<LogoutButton />
</div>
</header>
@@ -227,7 +277,7 @@ export default function AppShell() {
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
<NavLinks onNavigate={() => setDrawerOpen(false)} />
</nav>
<VersionFooter />
<SidebarFooter />
</div>
)}
<main {...stylex.props(styles.main)}>
+38
View File
@@ -0,0 +1,38 @@
import { health } from "@/lib/healthFixture";
import { diagnosticsBadge } from "./diagnosticsBadge";
test("a healthy box with nothing open wears no badge", () => {
expect(diagnosticsBadge(health(), false)).toBeNull();
});
test("open episodes are the count, warnings and errors together", () => {
const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 2, active_errors: 1 } }),
false,
);
expect(badge).toEqual({ text: "3", label: "3 active diagnostic events" });
});
test("one open episode is counted in the singular", () => {
const badge = diagnosticsBadge(
health({ diagnostics: { state: "recording", active_warnings: 0, active_errors: 1 } }),
false,
);
expect(badge).toEqual({ text: "1", label: "1 active diagnostic event" });
});
test("a degraded rollup with no open episode still shows, so no degraded state is invisible", () => {
const badge = diagnosticsBadge(health({ status: "degraded" }), false);
expect(badge).toEqual({ text: "!", label: "Health degraded" });
});
test("a failed poll is not evidence of health, badge and all", () => {
// Cached body says everything is fine; the poll that would have confirmed it
// never landed. An unbadged item here claims health on no evidence.
expect(diagnosticsBadge(health(), true)).toEqual({ text: "!", label: "Health unavailable" });
expect(diagnosticsBadge(undefined, true)).toEqual({ text: "!", label: "Health unavailable" });
});
test("the first poll being in flight is the one unknown that hides", () => {
expect(diagnosticsBadge(undefined, false)).toBeNull();
});
+37
View File
@@ -0,0 +1,37 @@
/**
* The count beside the Diagnostics nav item.
*
* Three things have to be visible and only one of them is a number. Open
* episodes are the count. A `degraded` rollup with no open episode still has to
* show something, or a degraded box looks exactly like a healthy one. And a
* health poll that failed is not evidence of health: it shows the same neutral
* mark, because the alternative is an unbadged item claiming all is well on no
* evidence at all.
*
* "Fresh" here means the latest poll succeeded, never TanStack's `isStale`:
* healthQuery's `staleTime` is 0, so staleness is true in every gap between
* polls and would badge the item permanently.
*/
import type { Health } from "@/lib/types";
export interface NavBadge {
/** What the badge shows. Shape and text, never colour alone. */
text: string;
/** What a screen reader hears in its place. */
label: string;
}
export function diagnosticsBadge(health: Health | undefined, pollFailed: boolean): NavBadge | null {
// The one hidden unknown, and only because it is momentary: the first poll has
// not answered yet, and there is nothing to be right or wrong about.
if (health === undefined && !pollFailed) return null;
if (pollFailed) return { text: "!", label: "Health unavailable" };
if (health === undefined) return null;
const open = health.diagnostics.active_warnings + health.diagnostics.active_errors;
if (open > 0) {
return { text: String(open), label: `${open} active diagnostic ${open === 1 ? "event" : "events"}` };
}
if (health.status === "degraded") return { text: "!", label: "Health degraded" };
return null;
}
+6 -4
View File
@@ -271,15 +271,17 @@ warning(disk_monitor): disk state ok -> critical: 33349095424 bytes free on /var
curl -s http://127.0.0.1:8080/api/health
```
`/api/health` needs no login and reports the state and what has been gated:
`/api/health` needs no login. It answers with five condition objects, and `status` is `degraded` when any one of them is in a degrading state:
```json
{"status":"degraded","disk":{"state":"critical","free_bytes":33349079040,"db_bytes":180224,"log_bytes":0,"sample_failures":0},"upstreams":{"available":1,"total":1},"queries_dropped":0,"writer_failed":false,"refreshes_gated":1,"snapshot_generation":2}
{"status":"degraded","protection":{"state":"active","until":null},"upstreams":{"state":"ok","available":1,"total":1},"query_history":{"state":"recording","dropped_total":0,"last_drop_s":null},"diagnostics":{"state":"recording","active_warnings":1,"active_errors":0},"disk":{"state":"critical","free_bytes":33349079040}}
```
`/metrics` carries the same free, database and log byte gauges as `nxdns_disk_free_bytes`, `nxdns_disk_db_bytes` and `nxdns_disk_log_bytes`; the state itself is on `/api/health`, not in the metrics output.
The disk monitor's `warn` state is reported here as `low`, because `warn` reads as a log level rather than as a quantity of disk.
**What the state means.** The monitor samples free space and database sizes once a minute. Below `disk.warn_free_mb` it logs the transition. Below `disk.min_free_mb` it gates every non-essential write: the query logger holds its batches, the client tracker stops persisting, and blocklist refreshes are skipped and counted in `refreshes_gated`. Resolution never degrades because the disk is full — this was verified by setting the thresholds above the free space on the volume: the state went critical, a refresh was gated, and queries kept being answered.
`/metrics` carries the free, database and log byte gauges as `nxdns_disk_free_bytes`, `nxdns_disk_db_bytes` and `nxdns_disk_log_bytes`, and counts gated refreshes as `nxdns_blocklist_refreshes_gated_total`. The disk state itself is on `/api/health`, not in the metrics output.
**What the state means.** The monitor samples free space and database sizes once a minute. Below `disk.warn_free_mb` it logs the transition. Below `disk.min_free_mb` it gates every non-essential write: the query logger holds its batches, the client tracker stops persisting, and blocklist refreshes are skipped and counted in `nxdns_blocklist_refreshes_gated_total`. A gate that has already cost query rows shows as `query_history.state = "losing"`. Resolution never degrades because the disk is full — this was verified by setting the thresholds above the free space on the volume: the state went critical, a refresh was gated, and queries kept being answered.
**Fix.** Recover space — lower `logging.retention_days`, or stop the service and delete `querylog.db` — and writes resume on the next sample.
+7 -3
View File
@@ -4,7 +4,7 @@ nxdns serves its admin API itself, on `web.bind:web.port` (default port 8080), a
The machine-readable contract is `src/web/openapi.yaml`, which the running server hands out unauthenticated at `GET /api/openapi.yaml`. Request and response schemas for every operation live there. When this page and the YAML disagree, the YAML wins.
The route table is `src/web/routes.zig`; the [Operations](#operations) table below carries all 61 of its entries.
The route table is `src/web/routes.zig`; the [Operations](#operations) table below carries all 63 of its entries.
## Conventions
@@ -108,8 +108,10 @@ Auth `open` means no session is required; `session` means a valid session cookie
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | read | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period |
| GET | `/api/stats/types` | session | counted | read | Query-type breakdown for a period |
| GET | `/api/stats/routes` | session | counted | read | How the period's queries were answered |
| GET | `/api/stats/clients` | session | counted | read | Per-client bucketed counts for a period |
| GET | `/api/lookup` | session | counted | read | Explain a domain |
| GET | `/api/upstream/health` | session | counted | read | Upstream pool health |
| GET | `/api/diagnostics` | session | counted | read | Operational event log |
| DELETE | `/api/diagnostics` | session | counted | runtime action | Purge every resolved event |
| GET | `/api/diagnostics/{id}` | session | counted | read | One operational event |
@@ -213,4 +215,6 @@ A non-empty `rewrites.cname_target` on a query detail means the decision landed
### Coverage
`GET /api/queries`, `GET /api/stats` and `GET /api/stats/timeseries` each answer with a `coverage` object: `available_since` is the oldest instant the query log is still complete for, and `complete` is true only when the window the request asked about starts at or after it. Retention deletes rows and advances the watermark in one transaction, so a client can tell an empty window from a pruned one instead of charting the gap as zero. A request with no lower bound at all asks about the whole of history, and is never complete.
Every window-bounded read — `GET /api/queries` and the five `GET /api/stats*` endpoints — answers with a `coverage` object: `available_since` is the oldest instant the query log is still complete for, and `complete` is true only when the window the request asked about starts at or after it. Retention deletes rows and advances the watermark in one transaction, so a client can tell an empty window from a pruned one instead of charting the gap as zero. A request with no lower bound at all asks about the whole of history, and is never complete.
Each of these responses reads its rows and its watermark inside one SQLite read transaction, so retention cannot prune between the two and hand back pre-prune rows tagged with a post-prune `available_since`. Coherence stops there: two separate requests are two separate reads, and queries logged between them can move the counts.
+1 -1
View File
@@ -147,7 +147,7 @@ The query-log writer commits one transaction per interval instead of one per que
What it costs:
- **Crash-loss window.** A process that dies takes roughly `interval` seconds of query history with it. That is the normal case, not a guaranteed maximum: a batch the disk monitor is holding back (free space below the critical threshold) or one waiting on a database write lock can be considerably older when the process dies. Power loss can additionally lose recent committed transactions, because `querylog.db` runs with WAL and `synchronous=NORMAL` — that was already true at any interval, and setting `0` does not buy per-query durability. Query history is the least valuable data on this box: nothing else depends on it, and it is deleted by retention anyway.
- **Staleness.** Every read backed by the query log — the query-log page, the dashboard totals, the timeseries — lags about `interval` seconds behind, and further behind while writes are gated or slow. The live view does not lag: it is fed from the SSE hub before the queue, so queries appear there the moment they are answered.
- **Staleness.** Every read backed by the query log — the query-log page, the Overview totals, the timeseries — lags about `interval` seconds behind, and further behind while writes are gated or slow. The live view does not lag: it is fed from the SSE hub before the queue, so queries appear there the moment they are answered.
`0` means "do not wait": the writer commits the entry that woke it together with whatever is already queued, up to 100 rows. Use it when you want the query-log page to be current to the second and you do not care what that costs the disk.
+1 -1
View File
@@ -211,7 +211,7 @@ One thing to know before you try other names: an entry in a hosts list blocks ex
## 12. Open the web interface
Visit <http://127.0.0.1:8080> in a browser. This is the single-page application you built in step 1, served out of the binary. The dashboard shows query and block counts, and the Blocklists page shows the source you added with its domain count. (The endpoints behind those two pages were checked while writing this; the browser page itself was not opened on the verification host.)
Visit <http://127.0.0.1:8080> in a browser. This is the single-page application you built in step 1, served out of the binary. The Overview shows query and block counts, and the Blocklists page shows the source you added with its domain count. (The endpoints behind those two pages were checked while writing this; the browser page itself was not opened on the verification host.)
## 13. Stop it
+146
View File
@@ -0,0 +1,146 @@
# Milestone 30: Overview replacement
Redesign step 4 of specs/ui-redesign.md (§Overview, §API changes /api/health, §Deletions), amended by the owner's 2026-08-22 ruling: the dashboard becomes Overview **with Pi-hole's dashboard layout** (stat tiles, two charts, two donuts — no status or issues sections; see S3/S2). The health contract becomes explicit — nothing may degrade the rollup without appearing in the response. Codex design review folded in (thread 01a028b1); corrections marked where they changed the shape.
**One amendment to the accepted design, made here and recorded in the changelog**: ui-redesign.md:113/203 lists an `upstream_history` health condition. After this milestone deletes the dashboard's upstream table, the upstream-minute history subsystem has no product consumer at all — a writer whose only reader is its own failure signal. The no-versioning rule forbids keeping it as a stub, so this milestone deletes the subsystem (S1.2) and the health contract has **five** conditions, not six.
## Sessions
S1 (done): Zig — health contract, subsystem deletion, contracts, docs. S3 (after S1): Zig — the three period aggregations. S2 (after S3): admin — the Pi-hole Overview, sidebar pause control, Diagnostics badge/strip, deletions, smoke. Sequential; **the milestone lands as one atomic commit** (Codex: S1 alone breaks the generated contract sample's TS assignment and every Health consumer — its red admin typecheck is an intra-milestone state, never a landed one).
---
## Session S1: the explicit health contract
### S1.1 `GET /api/health` (src/web/handlers/health.zig)
New body: `status` plus five condition objects. The degrading set is exactly (Codex: name it, don't gesture at "not ok"): protection `unavailable`, upstreams `unavailable`, query_history `losing`|`failed`, diagnostics `unavailable`, disk `low`|`critical`. Nothing else reaches `degraded()` — this kills the hidden `history_flush_failing` input (health.zig:65-73/177-178) along with its subsystem.
| object | shape | state rule |
| --- | --- | --- |
| `protection` | `{state: "active"\|"paused"\|"unavailable", until: ?i64}` | Precedence `unavailable``paused``active` (Codex). `unavailable` ⇔ no current filter snapshot exists (the condition under which handler.zig answers with `snapshot_unavailable` provenance) — pause state is irrelevant then, `until` null. Else `paused` ⇔ the pause is live now (indefinite → until null; timed and unexpired → until set). **An expired timed pause is `active`** (Codex). Paused does NOT degrade — operator choice, surfaced not alarmed; unavailable degrades. |
| `upstreams` | `{state: "ok"\|"unavailable", available, total}` | unavailable ⇔ available == 0. `total` = enabled routing candidates (the pool is built from enabled upstreams only — Codex); S2 labels it "of N enabled". |
| `query_history` | `{state: "recording"\|"losing"\|"failed", dropped_total, last_drop_s: ?i64}` | failed ⇔ `writer_failed`. losing ⇔ a logger-owned **gating-episode state** (Codex critical: `draining` is a shutdown flag, false during normal disk gating): logger marks the episode active while the disk gate holds writes, flips to losing once a drop occurs during that episode, clears when the gate reopens. Event-driven; no time window. Else recording. Queue-overflow drops outside a gating episode do not flip the state (cumulative ≠ current); they surface via `dropped_total` + `last_drop_s` (atomic stamped per drop, null until one happens). |
| `diagnostics` | `{state: "recording"\|"unavailable", active_warnings, active_errors}` | current rule (`!present or write_failed`) |
| `disk` | `{state: "ok"\|"low"\|"critical", free_bytes}` | disk_monitor states; **warn→low renamed at the serialization boundary**; `db_bytes`/`log_bytes`/`sample_failures` leave the body (Codex: sample failures already have a Prometheus counter and active `disk.probe` diagnostics — same ruling as refreshes_gated) |
All current top-level extras die: `queries_dropped`/`writer_failed` fold into `query_history`; `refreshes_gated` leaves health entirely (its Prometheus counter remains the record — Codex: table/prose disagreed, this is the ruling); `snapshot_generation` dies (no real admin consumer — Codex; generation stays in Prometheus, and protection `unavailable` is the UI-facing fact).
logger.zig work (S1-owned): the gating-episode state + `last_drop_s` atomic, stamped at the drop sites (logger.zig:687 area). **Episode start must be race-free** (Codex: runWriter fills a batch before checking the gate, so a naïve "mark on gate check" misclassifies gate-caused overflow drops as ordinary ones): the episode begins when the first pending batch observes the closed gate — before further timed filling — or via an equivalent race-free gate observation. Rollup tests rewritten per condition: each degrading state degrades alone and is visible in the body; paused does not degrade; expired pause reports active; protection unavailable wins over paused.
### S1.2 Delete the upstream-minute history subsystem
Extent (verified by exploration; the build agent must hit every item):
- src/upstream/history.zig (whole file) and src/storage/repositories/upstream_history_repo.zig (whole file).
- querylog_schema.zig: `upstream_targets` (:74), `upstream_minute` (:79), `idx_upstream_minute_ts` (:90), expected-object list :341-342. **This changes the schema fingerprint → querylog.db resets via the m28 aside mechanism; changelog states it.**
- app.zig wiring: :52,75,549-553 init/diagnostics/pool.history, :639 reopen path, :800 web state, :925-939 shutdown flush, :954 run task.
- pool.zig: `history` field :52,138-141 and `recordHistory` :377-386 (history-only). **`entry.health.recordSuccess/recordFailure` (:345,:364) and `recordDiagnostics` (:390+) are live routing/backoff/diagnostics state and must survive**; callsites :259,:269 keep those calls.
- retention.zig: history prune :19,134-135 + tests :457-505.
- Handler src/web/handlers/upstream_health.zig, route routes.zig:77, server.zig:43, openapi.yaml :501 path + :2122 enum, goldens.
- health.zig history signal (dies with the rollup rewrite anyway).
- metrics.zig: the four `nxdns_upstream_history_*` metrics :34,221,391-411 + tests :819-828.
- events.zig: the **emitter** enum entry dies, but the read side keeps the legacy code (Codex critical: rows survive, the list endpoint passes stored codes through, and dropping the union member would make real responses violate the contract; unresolvable active legacy episodes would also pin `untracked_active_count` and the SQL slow path forever). Contract: `upstream_history.write` stays in the openapi/TS event-code union and in eventCopy.ts, marked legacy; on store init/upgrade, any **active** `upstream_history.write` episode is resolved once, **with the open time as its resolution time** (not old `last_seen` — Codex: else the episode can be pruned in the same init), and **before the active-mirror load/count and pruning** (or the mirror is recomputed after) so `untracked_active_count` and the SQL fast path settle correctly. Test: an m29 database with an active flush-failure episode opens with that episode resolved and listable, `untracked_active_count` at 0, and the fast path in use.
- retention.zig: also `Retention.Stats.upstream_rows_pruned`, its atomic counter, and `nxdns_retention_upstream_rows_pruned_total` (Codex: they would remain forever zero).
- tests.zig :80,:84; affected web_integration_test.zig / resolver_integration_test.zig / pool.zig:937-980 tests.
- `stats.periodParam` stays (stats uses it).
- Sweep (S1.4 gate): case-insensitive grep over src/ docs/ PLAN.md for `upstream[_ -]history`, `upstream_minute`, `upstream_targets`, `upstream_rows_pruned`, `/api/upstream/health` — known stale spots include src/cli.zig, querylog_schema.zig WAL commentary, src/upstream/health.zig, metrics comments (the read-side legacy event code is the one allowed survivor).
### S1.3 Contracts and docs
openapi.yaml: Health schema rewritten (five objects, enums exact), upstream/health path+schema removed. Goldens regenerated; drift guards updated (route count 1; the health guard covers the nested objects). Docs are S1-owned (Codex): a prose sweep, not just cited lines — PLAN.md (upstream-history/API mentions :328,489-500,563 plus the summary and page/manual-check passages), docs/reference/api.md, docs/how-to/troubleshoot.md (old flat health JSON), docs/tutorial/first-run.md and docs/reference/configuration.md ("Dashboard" naming → Overview), stale comments in pool.zig. **specs/ui-redesign.md is S1-owned too** (Codex): amend §§event sources, API changes, recreation, deletion cost, and build sequence to the five-condition contract and the subsystem deletion, with a dated amendment note. CHANGELOG (S1 half): health contract break (pre-v0.1), subsystem + endpoint removal, querylog.db reset, warn→low, the design amendment.
Destructive-transition acceptance (Codex: generic fingerprint tests do not pin this): a test opens an m29-schema querylog.db, asserts it is renamed aside, recreated without the two tables, coverage restarts, and `query_log.recreated` is emitted. S2 updates the `query_log.recreated` eventCopy wording, which currently says the database "was unreadable" — false for a planned schema change; the copy must encompass both causes.
### S1.4 Acceptance (S1)
- [ ] Rollup tests per S1.1, incl. gating-episode losing-state transitions (gate holds without a drop → recording; drop during episode → losing; gate reopens → recording; writer death → failed) and `last_drop_s` stamping on a forced queue overflow.
- [ ] `zig build test` + `-Dintegration` 0 failed; goldens green; fmt clean; the S1.2 case-insensitive sweep finds nothing **except the allowed read-side legacy survivors**: the `upstream_history.write` code in the event union/openapi/read path and their tests (Codex: state the exception so the gate is passable). Admin is expectedly red until S2 — an intra-milestone state only; nothing is committed before S2's gates pass.
## Session S3: period aggregations (Zig)
**Owner ruling 2026-08-22**: after reviewing the built status-row Overview on a wide monitor, the owner replaced the three-section design with Pi-hole's dashboard layout ("just copy pi-hole's dashboard's layout; get rid of the current status section"). S1's health contract is unchanged. S3 adds the aggregations; S2 describes the final admin state. Build order: S1 (done) → S3 → S2.
All three aggregations read the query log over the same `[since,until)` Span the stats endpoints use (`stats.periodParam`, floor-aligned buckets), return `period/since/until/coverage` like the existing stats bodies, and honor `hide_domains`/`hide_client_ips` transforms where fields are sensitive. Pure SQL over m28's provenance columns — **no revival of the upstream-minute subsystem, no new writers, no new state.**
**Per-response atomicity** (Codex): each response — the three new endpoints AND the existing totals/timeseries — reads its aggregation and its `coverage` inside one SQLite read transaction, so retention cannot prune between the aggregate and the watermark, and the clients endpoint ranks and buckets from one database state. **Transaction ownership** (Codex critical): WebState shares one querylog_db connection across concurrent HTTP tasks with no mutex — SQLite serialized mode protects single calls, not transactions; concurrent BEGINs fail and foreign reads can interleave inside a transaction. S3 adds a `querylog_lock` covering every web-layer access to that connection (or separate read connections — implementer's choice, stated in the report), and the read transaction uses **deferred BEGIN**, not the existing `db.Tx` BEGIN IMMEDIATE (which would block the logger/retention writers); if a read-transaction abstraction is added, db.zig joins S3 ownership. Tests: concurrent stats/query requests under load; a prune racing a response never yields pre-prune data tagged with a post-prune `available_since`.
### S3.1 `GET /api/stats/types` — query-type breakdown
`{period, since, until, coverage, types: [{qtype: ?u16, count: u64}]}` — GROUP BY qtype. **No `name` field** (Codex: the only qtype-name mapping lives in admin qtype.ts; a Zig copy would drift — the UI labels codes with its existing `qtypeName()`). `query_log.qtype` is nullable: null groups into its own row (`qtype: null`), never silently dropped. Ordering: count DESC, then qtype ASC with null last (deterministic for goldens/colors). No zero rows.
### S3.2 `GET /api/stats/routes` — how queries were answered
`{period, since, until, coverage, routes: [{route: RouteKind-wire-string, source: ?string, count: u64}]}`. **Grouping key exact** (Codex: `source_name` is blocklist provenance, NOT the answering upstream): `upstream` rows group by `query_log.upstream`; `forward_zone` rows by `query_log.forward_zone`; `blocked`/`cache`/`local`/`rejected` rows have `source: null`. A null upstream/forward_zone identity on those route kinds is its own `source: null` row (UI labels it "Unknown"). Ordering: count DESC, then route ASC (the stored wire string, i.e. alphabetical: blocked, cache, forward_zone, local, rejected, upstream — not enum declaration order), then source ASC nulls last. This feeds the "Upstream servers" donut: blocked/cache/local/rejected shares by route kind, each upstream and forward zone by name.
### S3.3 `GET /api/stats/clients` — per-client timeseries
`{period, since, until, bucket_seconds, coverage, clients: [{client: string, buckets: [u64]}], other: [u64]}`**`bucket_seconds`, the established field name** (Codex). Top 8 clients ranked by total in-window count DESC then client ASC (deterministic cut); everything else sums into `other`, which is **always present and bucket-count-sized**, including empty windows and ≤8-client windows. Client strings arrive as stored — redaction is write-time (logger substitutes the hidden marker before the row exists), so the read path has no transform; hidden rows aggregate as one client named `hidden` (as-built, tested). Bucket alignment identical to `/api/stats/timeseries` so the charts share an x-axis; zero-filled; every series length equals the bucket count.
### S3.4 Contracts
Routes registered (session auth, stats rate-limit class), openapi paths + schemas, drift guards extended to the three bodies. Contract-sample seed traffic must exercise ≥2 qtypes, ≥2 route kinds and ≥2 clients. **contractSamples.gen.ts regeneration happens in S3** (Codex: the committed golden is TypeScript and byte-compared by W10 during integration; S3 cannot pass its own gate without it) — the handwritten-TS typecheck stays red until S2, same intra-milestone rule as S1.
Handler tests per endpoint: empty window, populated matrix, redaction transforms, coverage flag, null-qtype row, null-source row, the top-8 boundary (9th client folds into other), tie-order determinism. Empty-window bodies are exact: `types`/`routes` return empty arrays, `clients` returns zero-filled buckets with an empty `clients` list. **Conservation tests** (Codex, controlled-state only — see S2.2's window-coherence limit): over one shared span, `sum(types.count) == totals.queries`, `sum(routes.count) == totals.queries`, and per bucket `sum(named client series) + other == timeseries.queries` — catches null-loss, route omission, and bad partitioning.
### S3.5 Acceptance (S3)
- [ ] `zig build test` + `-Dintegration` 0 failed (W10 included); goldens green; fmt clean. Admin handwritten-TS red stays expected until S2.
## Session S2 (revised): Overview as Pi-hole's dashboard (admin)
### S2.1 Shell
- Nav: "Overview" first; root index redirects to `/overview`. **The header ProtectionIndicator is deleted** — no protection display in the header.
- **PauseControl moves to the sidebar bottom, directly above the version label — in BOTH sidebar renderings** (desktop rail and the mobile drawer, each of which has the version footer; Codex): same control, reading `useProtection()`, hidden while protection is unavailable/unknown, menu closes when state leaves active, mutation invalidates healthQuery. Existing PauseControl tests move with it plus placement tests for both renderings; the 390px smoke exercises the drawer. **The control states the pause with itself** (live-smoke gap, 2026-08-22: with the indicator and the status row both deleted, a paused resolver left no trace in the DOM outside `/diagnostics`, and "Resume" names an action without naming the state it would end): a state line rendered with the control in both renderings — `Paused until HH:MM` when `until` is set, `Paused` when it is null, nothing at all while active, since the button already says Pause. Text, never colour alone; the same `formatClock` and the same `protection` reading as the health strip, so the two cannot disagree, and `useProtection`'s expiry refetch retires both together. Tested for both paused shapes and for its absence while active.
- **Diagnostics nav item badge**: from healthQuery — `active_warnings + active_errors` as a count; degraded health `status` with zero events shows "!" so no degraded state is invisible; **a failed health poll shows a neutral "!" with accessible text "Health unavailable"** (Codex: unknown must not be unbadged — only initial loading may be); hidden only when health data exists, the latest poll succeeded, and status is ok with zero events — **"fresh" means exactly that, never TanStack staleness** (Codex: healthQuery's staleTime is 0, so `isStale` would badge every gap between polls). Count/shape+text, never color alone. Tests: 0 hidden, N shown, degraded-zero-events "!", failed-poll "!", initial-loading hidden.
- **Diagnostics page health strip**: the five S1.1 conditions rendered compactly at the top; healthy quiet, degraded highlighted. **Full load contract migrates from the deleted StatusSection** (Codex): visible loading before first health response; on refetch failure an error row with Retry, cached conditions marked stale, never presented as current; recovery clears the staleness. **Exact link matrix, restated** (Codex: no dangling reference): protection unavailable → `/blocklists`; upstreams unavailable → `/upstreams`; query history losing → in-page filter `component=disk`; failed → `component=query_log`; disk low/critical → `component=disk`; diagnostics unavailable → no link, copy "Diagnostics are not being recorded. Check free disk space and the configuration database's permissions." In-page filter links **set `component` and clear incompatible active filters** (severity/state stay default). The drops secondary text ("N queries dropped, last at HH:MM"; "N queries dropped" when `last_drop_s` null) lives on the query-history entry.
### S2.2 The page — Pi-hole's layout with our data
**Period is URL state**: `/overview?period=1h|24h|7d|30d` validated in the route search, default `24h`; the picker navigates (functional search update); deep-link and reload tests (Codex: the accepted time-scoping contract keeps shareable period state; the layout ruling did not revoke it).
Top to bottom, edge-to-edge grid, no status/issues sections:
1. **Four stat tiles**, neutral chrome (owner ruling: no colored accents; emphasis via value typography): Queries, Blocked (count + %), Clients, Avg response. Footer links: Queries → `/activity?mode=history&since&until` (returned bounds); Blocked → same + `blocked=true`; Clients → `/clients`; Avg response → none. Period picker in the section header governs every panel.
2. **Queries over time** — the existing stacked chart, full width.
3. **Client activity over time** — new stacked chart from `/api/stats/clients`: one series per named client + "other", same x-axis, legend with client names. **Series are labelled by registered name where one exists** (owner, 2026-08-22): the frontend resolves address → name at render time through the existing `useClientNames()` / `clientLabel()` lookup the query tables already use — hand-typed `name` first, reverse-DNS `learned_name` behind it, the bare address when the clients list knows neither — and the legend and the hidden table carry the same resolved string, so the graphic and the accessible surface never name one client differently. **Colour keys on the stored address regardless**, so registering or renaming a client never repaints its series. `clientsQuery` joins the loader's fire-and-forget set and polls on the same 30s cadence as the tables. Tests: named client renders its name, unregistered renders its address, and the swatch of a named series is still the colour of its address.
4. **Query types** and **Upstream servers** donuts from `/api/stats/types` / `/api/stats/routes`. Two-column row **only above a named breakpoint (1280px); stacked below** (Codex). **Stacked, the ring and its legend are centred in the panel** (owner, 2026-08-22: full-page width with the ring pinned left reads as a mistake); above the breakpoint each donut is one of a pair and stays left-anchored, in line with the panels above it. Labels: qtype via the existing `qtypeName()` (one home, `TYPE<n>` fallback, "Unknown" for null); routes via route-kind labels ("Blocked", "Cache", "Local", "Rejected") and source names ("Unknown" for null source on upstream/forward-zone rows). New reusable SVG donut, **non-focusable SVG + visible legend + a visually-hidden table carrying every label/count** (Codex: pin the pattern, no delegated a11y choice). **Empty windows render "No queries in this period."** in both donuts and the client chart — never a blank panel or a division by zero (tested). Legend/table identity and React keys are the composite `(route, source)` / qtype value — display names may collide ("Unknown" twice, same source name on two kinds), so ambiguous entries carry secondary route-kind text. **Series/slice colors key on semantic identity** — qtype value, client string, and for routes: fixed colors for the source-less route-kind slices (Blocked/Cache/Local/Rejected) while upstream/forward-zone slices key on the full `(route, source)` pair (Codex: keying on route kind alone would merge adjacent upstream slices); "other" fixed — so a rank change between refreshes never recolors an entry. The donut SVG carries `aria-hidden="true"` and `focusable="false"` (Codex: non-focusable alone does not leave the accessibility tree; the legend + hidden table are the accessible surface).
**Window coherence, five requests** (totals, timeseries, clients, types, routes). This is **window coherence, not data-snapshot coherence** (Codex: matching identities cannot prove a common database state — live inserts between requests may shift counts slightly between panels, and that is accepted; conservation holds in controlled tests only). The page holds one **coherent window** identified by `(period, since, until, coverage.available_since)` (coverage joins the identity — retention advancing between requests must not mix pre/post-prune windows). Window selection comparator (Codex): newer `until` wins; for equal bounds, newer `available_since`; tested where a bucket boundary and a coverage advance cross. a response is a member only if all four fields match. Rendering is per-panel against the window: panels whose data matches render; a panel whose request is loading shows its own loading state; a panel whose request failed shows its own error+Retry — **other panels keep rendering the coherent window** (a failed donut never blanks the charts). Laggards: every response behind the window is refetched **once per endpoint-keyed mismatch episode** (episode key = endpoint + window identity); a retry token invalidates all stale completions, including across period changes; previous-period placeholder data never participates. If an endpoint stays behind after its retry, that panel shows the error state; the page never renders two windows at once. One CoverageNotice, derived from the window's watermark. Tests: one lagging endpoint refetched once then coherent; still-behind terminal error per panel; period change with in-flight stale completions; coverage-advance mismatch; per-panel failure isolation.
### S2.3 Pause beside blocked queries — unchanged
RelatedActions on both detail surfaces, shared control, hidden when unavailable/unknown.
### S2.4 Migration and smoke
- Delete: StatusSection, statusRows, IssuesSection, ProtectionIndicator (+ their tests — behaviors re-pinned on the sidebar control/badge/health strip or declared dead in the report; "unknown never renders active" re-pins on the sidebar PauseControl and badge). **The `/overview` route loader is replaced** (Codex): drop the active-diagnostics prefetch, prefetch the five stats endpoints + health; sweep stale loader comments/imports and stale ownership comments in PauseControl, protection.ts, OverviewPage, ActivitySection.
- types.ts for the three new endpoints (hand-written halves); healthQuery consumers updated (badge, strip, control).
- Tests per S2.1/S2.2 lists plus root redirect.
- Smoke on the real binary (smoke28 config, traffic across ≥2 clients and ≥2 qtypes): screenshots at 1280/1920/2560/390 — healthy Overview fully populated (tiles, both charts, both donuts); paused via the sidebar control (desktop + drawer); Diagnostics badge + health strip with a tripped blocklist-refresh diagnostic (a dead forward zone produces SERVFAIL but no diagnostic); the Queries tile link landing on the exact window; `/overview?period=1h` deep link. Prefix m30smoke-.
### S2.5 Acceptance (S2)
- [ ] Admin gates clean; `zig build -Dadmin-dist=admin/dist` succeeds; zig suites untouched-green.
- [ ] Screenshots per S2.4.
## File ownership
S1: done (src/**, PLAN.md, docs/**, specs/ui-redesign.md, CHANGELOG S1 half). S3: src/web/** (three handlers, routes.zig, openapi.yaml, goldens incl. contractSamples.gen.ts regeneration, web_integration_test.zig), src/storage/repositories/queries_repo.zig (aggregations), **PLAN.md and docs/reference/api.md again** (Codex: PLAN still says health is surfaced on Overview with status+issues; both need the three new routes and the Overview description fixed), CHANGELOG (S3 lines). S2: admin/src/**, CHANGELOG (S2 half), specs/ui-redesign.md re-amendment covering **Overview, shell placement, API additions, time scoping, deletions, and build sequence** (dated note; the Pi-hole ruling supersedes the three-section prose). Sequential S3 → S2; one commit.
## Anti-requirements
- No new health inputs beyond the five objects; no polling-cadence changes.
- No revival of the upstream-minute subsystem — the routes breakdown is a query-log aggregation.
- No charting dependency; SVG only, donuts included.
- No colored tile accents (owner ruling); color carries state on values only.
- No Zig qtype-name mapping (the admin's qtype.ts stays the one home).
- No task-shaped configuration pages (step 5); no contract-closure sweep (step 6).
- Paused protection must not degrade `status`.
## Acceptance (milestone complete)
- [ ] All suites green (zig, integration, goldens, admin); one signed commit; screenshots per S2.4.
- [ ] CHANGELOG documents the health contract break, the subsystem removal + querylog.db reset, warn→low, the three new stats endpoints, the Pi-hole Overview, and the ui-redesign amendments.
+27 -21
View File
@@ -2,6 +2,10 @@
Author: Codex (gpt-5.6-sol, extra-high effort), 2026-08-19. **Not accepted yet.** Untracked on purpose until Mokhtar rules on the open questions at the end.
**Amendment, 2026-08-22 (milestone 30).** Step 4 deletes the upstream-minute history subsystem outright rather than keeping its health condition. Once Overview loses the upstream table, the subsystem has no product consumer at all — a writer whose only reader is its own failure signal — and the no-versioning rule forbids leaving it as a stub. So `/api/health` carries **five** conditions, not six: there is no `upstream_history` object. The `upstream_history.write` event code survives as **legacy on the read side only**: stored rows keep their code, the list endpoint passes it through, and the event store resolves any still-open episode once at init. The passages below are amended in place.
**Second amendment, 2026-08-22 (milestone 30, owner ruling after reviewing the built page on a wide monitor).** Overview is **not** three sections. It takes Pi-hole's dashboard layout: four stat tiles, two full-width charts, two breakdown donuts, and no current-state readout at all. The status rows and the active-issues list are withdrawn from Overview — the five health conditions become a compact strip at the top of the **Diagnostics** page, where the episodes that explain them already live, and the open-episode count becomes a badge on the Diagnostics navigation item. The shell's protection indicator is deleted outright; the Pause control moves to the foot of the sidebar, in both the desktop rail and the mobile drawer. Three period aggregations are added to feed the new panels. The §Overview, §API changes, §Time scoping, §Deletions and §Build sequence passages below are amended in place; where the older three-section prose survives elsewhere, this note supersedes it.
Answers that shaped it: the server and API may change; the surface-ownership split is right; file-mode configuration pages are read-only; diagnostics are curated structured events in the vein of Pi-hole's; time scoping is per workflow; a past query must be explainable exactly; Query Log and Live merge.
## Navigation
@@ -44,32 +48,31 @@ Keeps primary navigation because identifying and naming unknown devices is an op
## Overview
Three sections, nothing else.
One question, answered over a period the reader chooses: what did the resolver do. No current-state readout — that moved to Diagnostics (§Diagnostics) — and no active-issues list.
**1. Current status.** Five current facts, each conveyed by text and icon as well as colour. Healthy rows stay quiet; degraded rows link to the diagnostic or configuration surface that explains them.
**Period is URL state.** `/overview?period=1h|24h|7d|30d`, validated in the route search, defaulting to 24h. The picker navigates, so a view of the page is a link.
| Status | Shows | Why it belongs |
| --- | --- | --- |
| Protection | Active, paused until a timestamp, or unavailable, with Pause/Resume | Confirms filtering is in force, and carries the valid runtime action |
| Upstreams | available / configured, now | Confirms DNS can leave the network |
| Query history | Recording, losing rows, or writer failed | Says whether Activity can be trusted |
| Diagnostics | Recording or unavailable | A failure reporter that cannot record failures must itself be visible |
| Storage | ok / low / critical, free bytes | Says whether writes are safe, and explains write gating |
Top to bottom, edge to edge:
The shell carries a small global "Protection active/paused" indicator linking back to Overview. The controls themselves stay on Overview and beside blocked-query details.
1. **Four stat tiles**, neutral chrome throughout — no coloured accents; emphasis is typographic. Queries, Blocked (count and rate), Clients, Average response. Each tile carries the way into the rows behind its number: Queries and Blocked open Activity for exactly the bounds the stats response returned, Clients opens the clients page, and Average response has nothing to open.
2. **Queries over time** — the existing query-volume timeline, split blocked/cached/other, full width.
3. **Client activity over time** — one stacked series per named client plus "other", on the same bucket alignment as the timeline so the two charts share an x-axis. A client registered under a name is labelled by it, with the same precedence the query tables apply and the address kept as the title; colour keys on the address, so naming a client never repaints its series.
4. **Query types** and **Upstream servers** — two donuts, side by side above 1280px and stacked below, with the ring and its legend centred in the panel while stacked and left-anchored once they are a pair. Types are labelled by the admin's own `qtypeName()`; routes by route-kind labels and by the answering resolver or zone. Each donut's SVG is decoration (`aria-hidden`, `focusable="false"`); a visible legend and a visually hidden table are the accessible surface. An empty window says "No queries in this period." rather than drawing nothing.
**2. Active issues.** Severity, short title, affected object, how long it has been active, link to the detail. When none exist, one restrained line: "No active operational issues." Resolved failures never appear here, and healthy subsystems never get permanent green cards.
Colours key on semantic identity — the qtype value, the client string, the `(route, source)` pair — so a rank change between two polls never repaints an entry. Charts stay lightweight SVG; no charting dependency.
**3. Activity over a period.** The existing 1h / 24h / 7d / 30d control. Every value uses exactly the returned `[since, until)` window: queries, blocked count and rate, distinct clients, average response time, one query-volume timeline split blocked/cached/other, and "Open activity for this period" carrying the exact bounds. The timeline stays the existing lightweight SVG; no charting dependency.
**Window coherence, five requests.** Totals, timeseries, clients, types and routes are separate calls, and the page holds one window identified by `(period, since, until, coverage.available_since)` — the watermark joins the identity because retention advancing mid-page changes what the same span can answer for. A response is a member only if all four fields match. Rendering is per panel: a member renders, a panel still in flight shows its own loading state, a panel whose request failed shows its own error and Retry, and the members keep rendering throughout — a failed donut never blanks the charts. A response behind the window is refetched once per endpoint-keyed episode and, if it stays behind, that panel alone shows an error. This is window coherence, not data-snapshot coherence: live inserts between requests may shift counts slightly between panels, and that is accepted. One coverage notice for the page, from the window's watermark.
If the selected period predates available data, the section says "Query history is available from …" rather than charting the missing span as zero.
**The shell.** The header carries no protection display at all. The Pause/Resume control sits at the foot of the sidebar, above the version label, in both the desktop rail and the mobile drawer; it is the only global runtime action, and it belongs to the resolver rather than to any page. It still appears beside the detail of a query that was blocked. The control states a pause with itself — "Paused until 14:05", or "Paused" when the pause has no end — because "Resume" names an action without naming the state it would end, and with the indicator and the status rows both gone the sidebar is the only place a page other than Diagnostics can carry that fact. An active resolver gets no line; the button says Pause, which is the whole message. The line and the health strip read one `protection` condition through one clock format, so they cannot disagree. The Diagnostics navigation item carries a badge: the open-episode count, or a neutral "!" when the rollup is degraded with nothing open and when the latest health poll failed — an unknown must never read as healthy. It is hidden only when health data exists, the latest poll succeeded, and the rollup is ok with nothing open.
Removed from Overview: the historical upstream table, the "last failure" text, per-upstream period rates, the database and log byte breakdown, the standalone cache card.
Removed from Overview: the historical upstream table, the "last failure" text, per-upstream period rates, the database and log byte breakdown, the standalone cache card, the status rows, the active-issues list.
## Diagnostics
Not a journald viewer, and it does not subscribe to `std.log`. Producers emit a finite set of typed events at the failure boundary.
**The health strip (amended 2026-08-22).** The page opens with the five `/api/health` conditions rendered compactly: Protection, Upstreams, Query history, Diagnostics, Storage, each stating its state in words and an icon as well as colour. A healthy condition is quiet; a degraded one is highlighted and offers the way out. The link matrix is exact — protection `unavailable` to Blocklists, upstreams `unavailable` to Upstreams, query history `losing` to this page filtered to `component=disk`, `failed` to `component=query_log`, disk `low` or `critical` to `component=disk`, and diagnostics `unavailable` to nothing at all, because the surface a link would filter is the thing that is broken. An in-page filter link sets `component` and clears the time bounds, which could otherwise hide the very episodes it points at; severity and state keep whatever the reader chose. Dropped-row counts ride the query-history entry. Loading: a visible state before the first reading; on a refetch failure an error row with Retry, with the conditions on screen marked as the last reading that arrived rather than the current state, cleared when a poll succeeds again. This is the surface the withdrawn Overview status rows became.
### Event model
One row is one failure episode.
@@ -110,7 +113,6 @@ If the store itself cannot write, an atomic `event_store_failed` state appears i
| Certificate stat or reload failure | `certificate.reload`, keyed by `doh`/`dot` | files readable and reload succeeds |
| Query writer init or batch failure | `query_log.write`, keyed by `writer`/`batch`/`queue` | writer starts, or a batch succeeds without drops |
| Query retention prune/checkpoint/vacuum failure | `query_log.maintenance`, keyed by operation | that operation succeeds |
| Upstream-history flush failure | `upstream_history.write`, singleton | next flush succeeds |
| Client-name selection or persistence failure | `client_names.storage`, keyed by operation | next pass succeeds |
| Client materialisation or pruning failure | `clients.storage`, keyed by operation | next pass succeeds |
| Upstream exchange failure | `upstream.exchange`, keyed by upstream URL | next successful exchange |
@@ -118,7 +120,7 @@ If the store itself cannot write, an atomic `event_store_failed` state appears i
| Query-log recreation | `query_log.recreated`, one-shot | inserted resolved |
| Configuration warning leaving a capability skipped | `configuration.load`, keyed by setting | clean load after restart |
Sixteen codes. Codex proposed a seventeenth, `api.storage`, for a storage failure that produced an HTTP 500 — cut on review, because it breaks this section's own exclusion rule: a 500 already answered its caller. Do not merge the remaining codes to shrink the count either. A merged code forces `subject_key` to carry what the code no longer says.
Fourteen codes emitted. A fifteenth, `upstream_history.write`, is legacy and read-side only: milestone 30 deleted its producer and its emitter enum member, and it survives in the documented wire union — fifteen values in all — so stored rows stay readable and in-contract. Codex proposed a sixteenth, `api.storage`, for a storage failure that produced an HTTP 500 — cut on review, because it breaks this section's own exclusion rule: a 500 already answered its caller. Do not merge the remaining codes to shrink the count either. A merged code forces `subject_key` to carry what the code no longer says.
An upstream event describes a consecutive failure episode, not one row per retry. One timeout followed by success is one resolved episode.
@@ -180,7 +182,7 @@ Live SSE events carry the same provenance shape without a persisted `id`. A froz
One contract: unix seconds UTC, `since` inclusive, `until` exclusive, point data qualifies on `since <= ts < until`, diagnostic episodes qualify when their active interval overlaps the range, current state is labelled "Now" and no historical selector touches it.
URLs: `/overview?period=24h` with the server returning the exact aligned bounds; `/activity?mode=history&since=…&until=…` with `domain`, `client`, `blocked` and the other filters in the URL; `/diagnostics?since=…&until=…&severity=…&component=…`; `/activity?mode=live` with Follow/Freeze as ephemeral UI state. Investigation links always carry absolute bounds, so a viewed incident does not drift as time passes. Timestamps display in the browser's timezone; URLs and APIs stay timezone-independent.
URLs: `/overview?period=24h`, validated in the route search and defaulting to 24h, with the server returning the exact aligned bounds and every panel of the page judged against one window identity that includes the coverage watermark; `/activity?mode=history&since=…&until=…` with `domain`, `client`, `blocked` and the other filters in the URL; `/diagnostics?since=…&until=…&severity=…&component=…`; `/activity?mode=live` with Follow/Freeze as ephemeral UI state. Investigation links always carry absolute bounds, so a viewed incident does not drift as time passes. Timestamps display in the browser's timezone; URLs and APIs stay timezone-independent.
## File mode
@@ -200,7 +202,7 @@ Database mode uses the same information architecture with real edit actions, plu
`GET /api/config/status``{authority, path, reconciled_at, restart_pending}`. `restart_pending` is process state: database-mode mutations that need a restart set it, a successful restart clears it.
`GET /api/health` becomes explicit about every condition that contributes to degradation — `protection`, `upstreams`, `query_history`, `upstream_history`, `diagnostics`, `disk`, each an object with its own state. The current hidden `history_flush_failing` contribution is eliminated: nothing may degrade the rollup without appearing in the response.
`GET /api/health` becomes explicit about every condition that contributes to degradation — `protection`, `upstreams`, `query_history`, `diagnostics`, `disk`, each an object with its own state. Nothing may degrade the rollup without appearing in the response, so the hidden `history_flush_failing` contribution goes, and so does the subsystem behind it. The degrading set is exactly: protection `unavailable`, upstreams `unavailable`, query history `losing` or `failed`, diagnostics `unavailable`, disk `low` or `critical`. A paused protection is surfaced, never alarmed. The disk monitor's `warn` is renamed `low` at the serialization boundary. `queries_dropped`, `writer_failed`, `refreshes_gated` and `snapshot_generation` leave the body; the first two fold into `query_history`, and the last two stay in Prometheus.
`GET /api/diagnostics?state=&severity=&component=&since=&until=&limit=&before=` returns `{events[], next_before, active:{warnings, errors}}`. `GET /api/diagnostics/{id}` returns one event or 404 after retention. No acknowledgement, dismissal, generic-action or raw-log endpoints.
@@ -208,6 +210,8 @@ Database mode uses the same information architecture with real edit actions, plu
`GET /api/stats` and `/api/stats/timeseries` add `complete` and `available_since`.
**Three period aggregations (added 2026-08-22)** to feed the new Overview panels, all taking the same `period` parameter and reporting over the same aligned window, and all reading their rows and their coverage watermark inside one deferred SQLite read transaction. `GET /api/stats/types``{period, since, until, coverage, types:[{qtype, count}]}`, the numeric type only — naming types stays the admin's job, and a second table in the server would drift out of agreement with it — with the rows that recorded no type kept as their own `null` group. `GET /api/stats/routes``{period, since, until, coverage, routes:[{route, source, count}]}`, grouping `upstream` rows by the answering resolver and `forward_zone` rows by the zone, with blocked, cache, local and rejected carrying no source. `GET /api/stats/clients``{period, since, until, bucket_seconds, coverage, clients:[{client, buckets}], other}`, bucketed exactly as `/api/stats/timeseries`, the eight busiest clients named and everything else summed into `other`, which is always present and always bucket-count-sized. No new writers and no new state: all three are pure reads over the query log's provenance columns.
Existing mutation endpoints stay specific. Diagnostics introduces no generic "perform remediation" endpoint; it invokes the existing blocklist-refresh and certificate-reload operations.
## Schema changes
@@ -249,7 +253,7 @@ No new provenance table, no key/value store — household retention makes nullab
`transformed()` must hide `matched`, `cname_target` and `safe_search_target` under `hide_domains`, not only `domain`.
On recreation: query rows, provenance and upstream-minute history reset together as today; the old file stays aside; `config.db` diagnostics survive; a resolved `query_log.recreated` event records the reason, the aside path and the new coverage start; Overview and Activity report the incomplete range instead of charting zero.
On recreation: query rows and provenance reset together; the old file stays aside; `config.db` diagnostics survive; a resolved `query_log.recreated` event records the reason, the aside path and the new coverage start; Overview and Activity report the incomplete range instead of charting zero.
## Deletions and their cost
@@ -258,7 +262,9 @@ On recreation: query rows, provenance and upstream-minute history reset together
| Separate Query Log and Live pages | separate bookmarks; both modes remain in Activity |
| Standalone Lookup page | a top-level bookmark; testing remains under Activity |
| Top-level Groups, Blocklists, Rules, Local DNS, Upstreams, Settings | direct resource navigation; all capabilities remain under task-shaped configuration |
| Historical upstream table on Overview | at-a-glance period rates; availability stays, failures move to Diagnostics |
| Historical upstream table on Overview, and the upstream-minute history subsystem behind it | at-a-glance period rates, and the ranged per-upstream counts entirely; availability stays on the Diagnostics health strip, failures are Diagnostics episodes. `querylog.db` resets on the schema change |
| Overview status rows and active-issues list (2026-08-22) | a current-state readout on the landing page; the five conditions move to the Diagnostics health strip and the open count to the Diagnostics nav badge |
| Shell protection indicator (2026-08-22) | protection stated on every page; the sidebar Pause control carries it, its label for the action and its state line for a pause, and the health strip states it in full |
| "last failure · 9h ago" text | nothing actionable; the episode becomes a diagnostic |
| Detailed DB/log byte gauges | exact component sizes stay in Prometheus; free space stays on Overview |
| Standalone cache card | one prominent number; cache stays in the timeline and metrics |
@@ -279,7 +285,7 @@ Each step leaves the app working and shippable, and updates its OpenAPI contract
Parsed-SERVFAIL logging reverses ruling 20: `handler.zig:507` counts today rather than logging. The `Context` exists at every `servFail` site that follows question parsing. Pre-parse failures correctly stay counters.
3. **Activity consolidation.** The unified History/Live surface, URL filters, freeze/follow, live detail, current-policy test, historical detail links. Query Log, Live and Lookup routes and code are removed in the same change. Route, SSE, accessibility, reconnect and bounded-buffer tests.
4. **Overview replacement.** Current status, active diagnostics, one coherent activity section. New health contract and completeness states. The upstream-history table, stale-failure text, detailed DiskCard and cache card go.
4. **Overview replacement.** New five-condition health contract and completeness states, the three period aggregations, and — per the 2026-08-22 ruling — Pi-hole's dashboard layout: four stat tiles, the query-volume and per-client charts, the types and routes donuts, all against one coherent window. The five conditions become the Diagnostics health strip and the nav badge; the shell indicator dies and Pause moves to the sidebar foot. The upstream table, stale-failure text, detailed DiskCard and cache card go, and the upstream-minute history subsystem goes with the table — `src/upstream/history.zig`, its repository, its two `querylog.db` tables, `GET /api/upstream/health` and its four Prometheus metrics. Deleting the tables changes the query-log fingerprint, so this step resets `querylog.db` the same way step 2 does.
5. **Task-shaped configuration and file mode.** `/api/config/status` and server-owned `restart_pending`. Protection, Resolution and System in both read-only and editable forms. Clients and its detail route redesigned. Old configuration routes replaced atomically; global banner and disabled forms removed.
6. **Contract closure.** Remove obsolete queries, types, stores, CSS, tests and route fixtures. Regenerate contract samples, update OpenAPI and reference docs, add cross-surface acceptance tests for investigation links, file authority, query-log recreation, active-event recovery and time bounds. Zig, frontend, integration, accessibility and byte-budget checks; no new dependency.
+167 -23
View File
@@ -49,7 +49,6 @@ const faults = @import("config/faults.zig");
const fetcher = @import("filter/fetcher.zig");
const forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig");
const history_mod = @import("upstream/history.zig");
const http_util = @import("web/http_util.zig");
const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig");
@@ -72,7 +71,6 @@ const shutdown = @import("server/shutdown.zig");
const sse = @import("web/sse.zig");
const static = @import("web/static.zig");
const tcp_server = @import("server/tcp_server.zig");
const upstream_history_repo = @import("storage/repositories/upstream_history_repo.zig");
const transport = @import("upstream/transport.zig");
const udp_server = @import("server/udp_server.zig");
const validate = @import("config/validate.zig");
@@ -544,13 +542,6 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
);
// On the heap, not in this frame: the accumulator carries its pending cells
// and the flush task's buffer inline, which is about a megabyte.
const history = try gpa.create(history_mod.Accumulator);
defer gpa.destroy(history);
history.* = .init;
history.diagnostics = event_store;
pool.history = history;
pool.diagnostics = event_store;
// -----------------------------------------------------------------------
@@ -636,8 +627,6 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
reportQuerylogRecreated(event_store, io, boot_now_s, &querylog_opened, &querylog_writer_db);
var querylog_retention_db = try data.reopenQuerylogDb(io);
defer querylog_retention_db.close();
var querylog_history_db = try data.reopenQuerylogDb(io);
defer querylog_history_db.close();
var tracker_db = try data.openConfigDb(io);
defer tracker_db.close();
@@ -797,7 +786,6 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
.local_tables = &tables,
.logger = &query_logger,
.retention = &retention,
.history = history,
.sessions = if (sessions) |*s| s else null,
.limiter = if (web_limiter) |*l| l else null,
.hub = hub,
@@ -921,22 +909,19 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// Ruling 4's shutdown order, on the one path every exit from here takes:
// every producer stops and is joined, then the queue closes, then the
// writer is awaited — so the last batch is written rather than raced — and
// only then does the final history flush run, with no recording task left
// that could add a cell after it. A writer the disk gate will not let write
// counts its batch as dropped instead of holding the exit open
// (`logger.zig`), so this wait always ends.
// writer is awaited — so the last batch is written rather than raced. A
// writer the disk gate will not let write counts its batch as dropped
// instead of holding the exit open (`logger.zig`), so this wait always
// ends.
//
// A `defer` and not straight-line code after `shutdown.wait`, because a
// `concurrent` spawn below can fail with the DNS listeners already
// serving; an orderly error teardown owes the operator the same drain a
// signal gets. The `querylog_history_db` this flush writes through is
// declared above, so its `close` runs after it.
// signal gets.
defer {
group.cancel(io);
query_logger.shutdown(io);
writer_future.await(io) catch {};
history.flushOnce(io, &querylog_history_db, upstream_history_repo.flush);
}
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
@@ -949,9 +934,6 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
if (dot_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io });
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db, gate, event_store });
// Ungated: a flush writes at most one row per upstream per minute, the same
// category as the query logger's own writes, which are ungated too.
try group.concurrent(io, history_mod.Accumulator.run, .{ history, io, &querylog_history_db });
try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io, event_store });
try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate, &client_names_resolver });
@@ -1451,6 +1433,168 @@ test "the recreated detail names the aside and the new coverage start" {
);
}
/// The `querylog.db` schema as milestone 29 shipped it, verbatim from
/// `querylog_schema.zig` at commit fa323c7. A literal and not this build's DDL
/// with the deleted tables appended: the appended form drifts the moment the
/// surviving tables change, and its fingerprint was never the one an m29 file
/// on disk actually carries. The transition under test is that exact byte
/// sequence meeting this build.
/// The `PRAGMA user_version` an m29 file on disk carries, written down rather
/// than recomputed from the literal below. A CRC taken over the fixture
/// validates whatever the fixture happens to say, so a slip in the "byte-exact"
/// literal would still self-certify; pinning the historical number turns that
/// slip into a failure. Its value is `Crc32` over `querylog_schema.ddl` at
/// commit fa323c7.
const m29_fingerprint: i32 = 603440875;
/// A watermark from long before this test runs. Both schemas seed
/// `available_since` from `unixepoch()`, so a fixture left at its own default
/// would satisfy "the new file's coverage is not older" even if recreation
/// copied the replaced file's promise straight across.
const m29_available_since: i64 = 1_600_000_000;
const m29_ddl: [:0]const u8 =
\\CREATE TABLE domains (
\\ id INTEGER PRIMARY KEY,
\\ domain TEXT NOT NULL UNIQUE
\\);
\\
\\CREATE TABLE query_log (
\\ id INTEGER PRIMARY KEY,
\\ timestamp INTEGER NOT NULL,
\\ domain_id INTEGER NOT NULL REFERENCES domains(id),
\\ client_ip TEXT NOT NULL, -- text, not a FK: log rows are immutable facts
\\ qtype INTEGER,
\\ blocked INTEGER NOT NULL,
\\ response_time_us INTEGER,
\\ cache_hit INTEGER,
\\ upstream TEXT,
\\ qclass INTEGER NOT NULL,
\\ rcode INTEGER NOT NULL,
\\ group_id INTEGER, -- text/id pairs, not FKs: a renamed
\\ group_name TEXT, -- group must not rewrite history
\\ policy_action TEXT NOT NULL,
\\ policy_reason TEXT NOT NULL,
\\ matched TEXT,
\\ source_id INTEGER,
\\ source_name TEXT,
\\ cname_target TEXT,
\\ safe_search_target TEXT,
\\ route_kind TEXT NOT NULL,
\\ forward_zone TEXT,
\\ CHECK (rcode BETWEEN 0 AND 4095) -- twelve bits (RFC 6891 6.1.3)
\\);
\\CREATE INDEX idx_query_log_ts ON query_log(timestamp);
\\CREATE INDEX idx_query_log_client ON query_log(client_ip);
\\CREATE INDEX idx_query_log_domain ON query_log(domain_id);
\\
\\CREATE TABLE upstream_targets (
\\ id INTEGER PRIMARY KEY,
\\ url TEXT NOT NULL UNIQUE -- the historical identity: config.db ids cannot cross database files
\\);
\\
\\CREATE TABLE upstream_minute (
\\ upstream_id INTEGER NOT NULL REFERENCES upstream_targets(id),
\\ minute_ts INTEGER NOT NULL,
\\ successes INTEGER NOT NULL,
\\ failures INTEGER NOT NULL,
\\ last_failure_ts INTEGER,
\\ last_error TEXT,
\\ PRIMARY KEY (upstream_id, minute_ts),
\\ CHECK (successes >= 0),
\\ CHECK (failures >= 0)
\\) WITHOUT ROWID;
\\CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
\\
\\CREATE TABLE querylog_meta (
\\ id INTEGER PRIMARY KEY CHECK (id = 1), -- one row, enforced by the schema
\\ created_at INTEGER NOT NULL,
\\ available_since INTEGER NOT NULL
\\);
\\INSERT INTO querylog_meta (id, created_at, available_since)
\\VALUES (1, unixepoch(), unixepoch() + 1);
;
test "an m29 query log is set aside and recreated without the upstream-history tables" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
var path_buf: [256]u8 = undefined;
const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});
var fx: events_fixture.Fixture = .{};
try fx.init(io, 1000);
defer fx.deinit();
// The fixture is only worth anything while it is still a *different*
// schema from this build's, and one that carries the deleted tables.
try testing.expect(!std.mem.eql(u8, m29_ddl, querylog_schema.ddl));
try testing.expect(std.mem.indexOf(u8, m29_ddl, "CREATE TABLE upstream_minute") != null);
try testing.expect(std.mem.indexOf(u8, querylog_schema.ddl, "upstream_minute") == null);
// And only while it is still m29's bytes: this is the one check that an
// edit to the literal cannot satisfy by changing what it is compared to.
try testing.expectEqual(m29_fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(m29_ddl))));
// A healthy m29 file, stamped with the fingerprint m29's own DDL produced
// and backdated so its coverage promise is visibly the older one.
const m29_coverage = blk: {
var m29 = try db.Db.open(path, .{ .mode = .read_write_create });
defer m29.close();
try db.applyPragmas(&m29, .{});
try m29.exec(m29_ddl);
try m29.exec("INSERT INTO upstream_targets (url) VALUES ('https://dns.example/dns-query');");
var meta_buf: [128]u8 = undefined;
try m29.exec(try std.fmt.bufPrintZ(
&meta_buf,
"UPDATE querylog_meta SET created_at = {d}, available_since = {d};",
.{ m29_available_since, m29_available_since },
));
var version_buf: [64]u8 = undefined;
try m29.exec(try std.fmt.bufPrintZ(
&version_buf,
"PRAGMA user_version = {d};",
.{m29_fingerprint},
));
break :blk try m29.queryInt("SELECT available_since FROM querylog_meta");
};
try testing.expectEqual(m29_available_since, m29_coverage);
var opened = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer opened.database.close();
// Set aside under the name that says the file was healthy and this build
// moved, and still on disk for an operator who wants it.
try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, opened.recreated.?);
try testing.expect(std.mem.indexOf(u8, opened.aside(), ".schema-changed-") != null);
try tmp.dir.access(io, std.fs.path.basename(opened.aside()), .{});
// The two tables are gone from the file this process will write to.
for ([_][]const u8{ "upstream_targets", "upstream_minute", "idx_upstream_minute_ts" }) |name| {
var stmt = try opened.database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
defer stmt.deinit();
try stmt.bindText(1, name);
try testing.expect(try stmt.step());
try testing.expectEqual(@as(i64, 0), stmt.columnInt(0));
}
// Coverage restarts: the new file does not inherit the replaced one's
// promise about what it can answer. Strictly newer, not merely not-older —
// a recreation that copied the watermark across would pass the weaker test.
const coverage = try queries_repo.availableSince(&opened.database);
try testing.expect(coverage > m29_coverage);
reportQuerylogRecreated(&fx.store, io, 2000, &opened, &opened.database);
try testing.expectEqualStrings("query_log.recreated", try fx.text("SELECT code FROM operational_events"));
try testing.expectEqualStrings(
"fingerprint_mismatch",
try fx.text("SELECT subject_key FROM operational_events"),
);
}
test "a fingerprint recreate files a resolved event naming the real aside and watermark" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+4 -4
View File
@@ -343,10 +343,10 @@ pub const DataDir = struct {
}
/// An additional connection to a `querylog.db` that `openQuerylogDb` has
/// already established. A running server needs three background ones — the
/// log writer, the retention pass and the upstream-history flush each own
/// one (`retention.zig`'s contract) — plus a fourth for the web task when
/// the web interface is enabled.
/// already established. A running server needs two background ones — the
/// log writer and the retention pass each own one (`retention.zig`'s
/// contract) — plus a third for the web task when the web interface is
/// enabled.
pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
_ = io;
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
+225
View File
@@ -16,6 +16,7 @@
//! opaque, matching `src/platform/tls_server.zig`'s Mbed TLS approach.
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const log = std.log.scoped(.db);
@@ -844,6 +845,137 @@ pub const Tx = struct {
}
};
/// A read transaction: one consistent view of the file across several
/// statements.
///
/// `BEGIN DEFERRED`, not `Tx`'s `BEGIN IMMEDIATE`. A reader that took the write
/// lock would stall the logger and retention for the length of an HTTP
/// response; a deferred transaction that only ever reads never upgrades, so it
/// cannot hit the mid-way `SQLITE_BUSY` the `Tx` doc warns about. In WAL mode it
/// pins the snapshot at the first read, which is the point: an aggregate and the
/// coverage watermark beside it describe the same database state even when
/// retention prunes between them.
///
/// Ending it is fallible, and the caller must treat it that way. A connection
/// left inside a transaction refuses the next `BEGIN`, so a swallowed failure
/// here does not cost one response — it costs every later one on the same
/// connection, and they would each be a correct-looking answer over a foreign
/// snapshot or an outright error the client was never told about.
///
/// ```zig
/// var tx = try ReadTx.begin(db);
/// errdefer tx.rollback();
/// ... // reads only
/// try tx.commit();
/// ```
pub const ReadTx = struct {
db: *Db,
active: bool,
pub fn begin(db: *Db) Error!ReadTx {
try db.exec("BEGIN DEFERRED;");
return .{ .db = db, .active = true };
}
/// Ends the transaction, and says so. A read transaction has nothing to
/// conflict over, so a failed COMMIT means the connection is in a state
/// this code did not put it in: the ROLLBACK below is the attempt to hand
/// the next caller a usable connection anyway, and the error is returned so
/// the response it was serving fails rather than reporting success over a
/// database whose state nobody can name.
pub fn commit(self: *ReadTx) Error!void {
assert(self.active);
self.active = false;
self.execCommit() catch |err| {
self.reportFault("COMMIT");
self.forceRollback();
return err;
};
}
fn execCommit(self: *ReadTx) Error!void {
if (commitFaultTripped()) return error.Internal;
return self.db.exec("COMMIT;");
}
/// Safe in `errdefer` and after `commit`. Never returns an error: it runs
/// on the path where something has already gone wrong, and that error is
/// the one worth reporting.
pub fn rollback(self: *ReadTx) void {
if (!self.active) return;
self.active = false;
self.forceRollback();
}
/// A ROLLBACK that fails leaves the connection inside a transaction with no
/// way left to get it out. Every later `begin` on it fails, which is the
/// visible symptom this reports the cause of.
fn forceRollback(self: *ReadTx) void {
self.db.exec("ROLLBACK;") catch {
self.reportFault("ROLLBACK");
};
}
/// The only record that this connection may be unusable, so in a real build
/// it is `err`. A test that deliberately causes the fault captures it
/// instead — see `read_tx_faults`.
fn reportFault(self: *ReadTx, comptime what: []const u8) void {
var buf: [256]u8 = undefined;
const message = self.db.lastError(&buf);
if (faultCaptured()) return;
log.err("read-transaction " ++ what ++ " failed: {s}", .{message});
}
};
/// Drives and observes the read-transaction teardown faults, which a unit test
/// cannot arrange against a healthy SQLite connection. Test builds only; it
/// reduces to nothing everywhere else — the rotation seam's shape
/// (logging.zig).
const read_tx_seam = if (builtin.is_test) struct {
var fail_next_commit: bool = false;
var capturing: bool = false;
var faults: usize = 0;
} else struct {};
fn commitFaultTripped() bool {
if (!builtin.is_test) return false;
if (!read_tx_seam.fail_next_commit) return false;
read_tx_seam.fail_next_commit = false;
return true;
}
/// True when a test has said it is expecting this fault and will assert on it.
/// Capture is opt-in for exactly one reason: the test runner fails a test that
/// logs at `err`, so a blanket silence would turn an unexpected teardown fault
/// in some unrelated test into a silent pass.
fn faultCaptured() bool {
if (!builtin.is_test) return false;
if (!read_tx_seam.capturing) return false;
read_tx_seam.faults += 1;
return true;
}
/// The seam's controls, for tests in this file and in the web layer.
pub const read_tx_faults = if (builtin.is_test) struct {
/// Fails the next `ReadTx.commit` before it issues COMMIT, so the
/// transaction is still open when the recovery path runs — which is the
/// shape of a real COMMIT failure.
pub fn failNextCommit() void {
read_tx_seam.fail_next_commit = true;
}
pub fn beginCapture() void {
read_tx_seam.capturing = true;
read_tx_seam.faults = 0;
}
/// Stops capturing and answers how many faults were reported meanwhile.
pub fn endCapture() usize {
read_tx_seam.capturing = false;
return read_tx_seam.faults;
}
} else struct {};
const testing = std.testing;
fn openMemory() Error!Db {
@@ -1029,6 +1161,99 @@ test "rollback after commit is a no-op" {
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
test "a read transaction sees one state and frees the connection when it commits" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
try db.exec("INSERT INTO t (id) VALUES (1);");
var tx = try ReadTx.begin(&db);
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
try tx.commit();
try testing.expect(!tx.active);
// Committed, so the connection takes a fresh transaction again.
var second = try ReadTx.begin(&db);
second.rollback();
try testing.expect(!second.active);
// Idempotent, so an `errdefer` that survives a successful rollback is a
// no-op rather than a stray ROLLBACK against the next transaction.
second.rollback();
var third = try ReadTx.begin(&db);
try third.commit();
}
test "a connection left inside a transaction refuses the next begin" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
// The poisoned connection, reached the only way it can be: a transaction
// that was opened and never ended. This is what a swallowed COMMIT failure
// would leave behind, and the point is that it is *loud* — the next reader
// gets an error it must report, never a silent read outside a snapshot.
try db.exec("BEGIN DEFERRED;");
try testing.expectError(error.Unexpected, ReadTx.begin(&db));
// And it is recoverable: ending the stray transaction restores the
// connection, which is what `commit`'s rollback attempt is reaching for.
try db.exec("ROLLBACK;");
var tx = try ReadTx.begin(&db);
try tx.commit();
}
test "a failed commit is reported, rolled back, and leaves the connection usable" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
try db.exec("INSERT INTO t (id) VALUES (1);");
read_tx_faults.beginCapture();
defer _ = read_tx_faults.endCapture();
var tx = try ReadTx.begin(&db);
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
// The failure lands where a real one does: the transaction is still open
// when the recovery path runs.
read_tx_faults.failNextCommit();
try testing.expectError(error.Internal, tx.commit());
try testing.expect(!tx.active);
// Exactly one fault: the COMMIT. The ROLLBACK behind it succeeded, which is
// the whole point of attempting it.
try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());
// The connection is not poisoned — the next reader gets a transaction
// rather than inheriting the fault.
read_tx_faults.beginCapture();
var next = try ReadTx.begin(&db);
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
try next.commit();
try testing.expectEqual(@as(usize, 0), read_tx_faults.endCapture());
}
test "an armed commit fault fires once and no further" {
var db = try openMemory();
defer db.close();
try applyPragmas(&db, .{});
read_tx_faults.beginCapture();
defer _ = read_tx_faults.endCapture();
read_tx_faults.failNextCommit();
var first = try ReadTx.begin(&db);
try testing.expectError(error.Internal, first.commit());
// The seam disarms itself, so it cannot leak into a later test in the same
// binary and fail a commit nobody asked to fail.
var second = try ReadTx.begin(&db);
try second.commit();
try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());
}
test "a row-producing statement reports its row through step" {
var db = try openMemory();
defer db.close();
+113 -10
View File
@@ -20,9 +20,8 @@
//! having no store.
//!
//! **Time is a parameter, not a seam.** Every method that stamps a row takes
//! `now_s` (`purge`/`purgeAll` only delete, so they take none),
//! matching `upstream/history.zig` and `storage/logger.zig`. Production callers
//! read `Clock.real`; tests pass literals. There is no clock in here and no
//! `now_s` (`purge`/`purgeAll` only delete, so they take none), matching
//! `storage/logger.zig`. Production callers read `Clock.real`; tests pass literals. There is no clock in here and no
//! function pointer standing in for one.
//!
//! **`resolve` is on the DNS hot path.** `pool.recordSuccess` calls it after
@@ -39,9 +38,15 @@ const events_repo = @import("repositories/events_repo.zig");
const log = std.log.scoped(.events);
/// Every failure episode nxdns can record. **The enum is the truth**: its
/// cardinality is what the exhaustive tests, the API and the frontend copy map
/// all count, and the dotted string is only its wire form.
/// Every failure episode nxdns can **emit**, and nothing else. **The enum is
/// the truth** about what a running process writes: the exhaustive tests count
/// it, and the dotted string is only its wire form.
///
/// It is not the whole documented union. The API and the frontend copy map hold
/// these plus `legacy_wire_codes`, because a stored row outlives its producer —
/// see there. A code whose producer is gone moves to that list rather than
/// staying here: a member here is a code `Store.report` accepts, and a report
/// under a dead code opens an episode nothing can ever resolve.
pub const Code = enum {
disk_space,
disk_probe,
@@ -52,7 +57,6 @@ pub const Code = enum {
query_log_write,
query_log_maintenance,
query_log_recreated,
upstream_history_write,
upstream_exchange,
client_names_storage,
clients_storage,
@@ -75,7 +79,6 @@ pub fn wire(code: Code) []const u8 {
.query_log_write => "query_log.write",
.query_log_maintenance => "query_log.maintenance",
.query_log_recreated => "query_log.recreated",
.upstream_history_write => "upstream_history.write",
.upstream_exchange => "upstream.exchange",
.client_names_storage => "client_names.storage",
.clients_storage => "clients.storage",
@@ -92,7 +95,6 @@ pub fn component(code: Code) []const u8 {
.blocklist_refresh, .blocklist_snapshot, .blocklist_storage => "blocklist",
.certificate_reload => "certificate",
.query_log_write, .query_log_maintenance, .query_log_recreated => "query_log",
.upstream_history_write => "upstream_history",
.upstream_exchange => "upstream",
.client_names_storage => "client_names",
.clients_storage => "clients",
@@ -101,6 +103,21 @@ pub fn component(code: Code) []const u8 {
};
}
/// Wire codes that stored rows still carry and no code emits.
///
/// They live as text, not as `Code` members, because the read path is the only
/// path that meets them: the list endpoint passes a stored code straight
/// through, and `events_repo.componentOf` derives the component from the text.
/// The one thing the store must still do with them is close whatever a past
/// release left open — see `Store.init`.
///
/// The documented event-code union is these plus every `Code`, which is what
/// keeps a real response describing an old row inside the contract.
pub const legacy_wire_codes = [_][]const u8{
// Milestone 30 deleted the upstream-minute history subsystem.
"upstream_history.write",
};
/// Fixed per emit call, not per code: the same disk monitor reports a
/// transition to `warn` as a warning and one to `critical` as an error.
///
@@ -198,6 +215,17 @@ pub const Store = struct {
pub fn init(io: std.Io, database: *db.Db, now_s: i64) db.Error!Store {
var store: Store = .{ .database = database };
// Before the mirror load and the prune, both of which would otherwise
// read a state this is about to change: an episode left open here would
// sit in the mirror forever and hold `untracked_active_count` above
// zero, and one resolved after the prune would keep its row for another
// ninety days. `now_s` is the resolution time on purpose — the store's
// own open time, not the episode's stale `last_seen`, which a database
// older than the retention window would prune in this very call.
for (legacy_wire_codes) |code| {
_ = try events_repo.resolveActiveByCode(database, now_s, code);
}
var chunk: [16]events_repo.ActiveRow = undefined;
var after: i64 = 0;
while (store.active.len < mirror_capacity) {
@@ -688,7 +716,7 @@ const testing = std.testing;
test "wire and component are exhaustive, unique and agree with each other" {
const all = std.enums.values(Code);
try testing.expectEqual(@as(usize, 15), all.len);
try testing.expectEqual(@as(usize, 14), all.len);
for (all, 0..) |code, i| {
const text = wire(code);
@@ -722,6 +750,7 @@ const Fixture = struct {
io: std.Io = undefined,
database: db.Db = undefined,
store: Store = undefined,
text_buf: [256]u8 = undefined,
fn init(self: *Fixture, now_s: i64) !void {
self.threaded = .init(testing.allocator, .{});
@@ -744,8 +773,82 @@ const Fixture = struct {
fn count(self: *Fixture, sql: []const u8) !i64 {
return self.database.queryInt(sql);
}
fn text(self: *Fixture, sql: []const u8) ![]const u8 {
var stmt = try self.database.prepare(sql);
defer stmt.deinit();
if (!try stmt.step()) return error.NoRow;
const value = stmt.columnText(0);
@memcpy(self.text_buf[0..value.len], value);
return self.text_buf[0..value.len];
}
};
test "an m29 flush-failure episode is resolved once at init and stays listable" {
var fx: Fixture = .{};
try fx.init(1000);
defer fx.deinit();
// The state an m29 database is left in: a live episode under a code that no
// producer emits any more, so nothing will ever resolve it. Seeded through
// the repository and not through `report`, because the live emitter cannot
// name this code — that it cannot is half of what m30 changed.
_ = try events_repo.insertActive(
&fx.database,
1000,
legacy_wire_codes[0],
"history",
"history",
"warning",
"Busy",
);
try testing.expectEqual(
@as(i64, 1),
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
);
// Reopening is what m30 upgrades on. Ninety days later, so a resolution
// stamped with the episode's own `last_seen` would be pruned by this very
// call rather than left for an operator to read.
const reopened_at = 1000 + Store.resolved_retention_s + 86_400;
const upgraded = try Store.init(fx.io, &fx.database, reopened_at);
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
try testing.expectEqual(
@as(i64, reopened_at),
try fx.count("SELECT resolved_at FROM operational_events"),
);
try testing.expectEqualStrings(
"upstream_history.write",
try fx.text("SELECT code FROM operational_events"),
);
// Nothing is open, so the mirror is empty and the SQL slow path is off.
try testing.expectEqual(@as(u32, 0), upgraded.active.len);
try testing.expectEqual(@as(u32, 0), upgraded.untracked_active_count);
// A second start finds nothing left to close, and the sweep is idempotent.
const again = try Store.init(fx.io, &fx.database, reopened_at + 60);
try testing.expectEqual(@as(u32, 0), again.active.len);
try testing.expectEqual(
@as(i64, reopened_at),
try fx.count("SELECT resolved_at FROM operational_events"),
);
}
test "a legacy wire code is not something the live emitter can name" {
for (legacy_wire_codes) |legacy| {
try testing.expectEqual(@as(?Code, null), parseWire(legacy));
for (std.enums.values(Code)) |code| {
try testing.expect(!std.mem.eql(u8, legacy, wire(code)));
}
// The read path still has to answer for it, and does so from the text.
const dot = std.mem.indexOfScalar(u8, legacy, '.') orelse
return error.TestUnexpectedResult;
try testing.expectEqualStrings(legacy[0..dot], events_repo.componentOf(legacy));
try testing.expect(legacy.len <= events_repo.code_capacity);
}
}
test "a failure opens one episode and repeats of it count rather than multiply" {
var fx: Fixture = .{};
try fx.init(1000);
+384 -16
View File
@@ -30,6 +30,7 @@
//! `writer_failed`, so the loss is visible rather than silent.
const std = @import("std");
const builtin = @import("builtin");
const db = @import("db.zig");
const disk_monitor = @import("disk_monitor.zig");
@@ -335,12 +336,85 @@ const Outcome = union(enum) {
expiry: std.Io.Cancelable!void,
};
/// Where the disk gate stands right now, as a state rather than a tally.
///
/// `open` is the steady state. `gated` says the gate is holding writes back but
/// nothing has been lost to it yet, and `losing` says this episode has already
/// cost rows. A cumulative drop counter cannot say any of that: it only ever
/// grows, so a rollup computed from it would latch on the first overflow.
pub const GateEpisode = enum(u8) { open, gated, losing };
/// The episode state and the identity of the episode it belongs to, in one
/// word so a compare-and-swap can test both at once.
///
/// The identity is what the state alone cannot carry. A producer decides a row
/// is lost, stalls, and wakes after the gate has closed, reopened and closed
/// again; a bare `gated -> losing` swap would then mark an episode that has
/// cost nothing. `generation` rises every time an episode opens, so that swap
/// fails against the newer episode and the stale loss is discarded.
const Gate = packed struct(u64) {
episode: GateEpisode,
generation: u56,
const initial: Gate = .{ .episode = .open, .generation = 0 };
fn bits(self: Gate) u64 {
return @bitCast(self);
}
fn of(bits_value: u64) Gate {
return @bitCast(bits_value);
}
};
/// Parks a producer immediately before it evicts an entry, so a test can move
/// the disk gate while that producer is still inside `enqueue` and the row it
/// is about to lose is still queued.
///
/// Nothing else reaches that point. `enqueue` never suspends, so from outside a
/// gate read before its loop and one read at the eviction give the same answer,
/// and no test could tell the two apart — which is exactly the difference that
/// decides whether a discard can join an episode that opened after the producer
/// started.
///
/// Before the eviction and not after it: parking after the row is already gone
/// would let a test open an episode and then file an earlier loss under it,
/// which is the misattribution the sampling rule exists to prevent. The loss
/// has to happen inside the episode for the episode to own it. The storage
/// exists in a test build only, and `park` reduces to nothing everywhere else —
/// the settings hash seam's shape (`web/handlers/settings.zig`).
const discard_stall = if (builtin.is_test) struct {
var armed: bool = false;
var parked: std.Io.Event = .unset;
var release: std.Io.Event = .unset;
fn park(io: std.Io) void {
if (!armed) return;
parked.set(io);
release.waitUncancelable(io);
}
} else struct {
fn park(io: std.Io) void {
_ = io;
}
};
pub const Logger = struct {
cfg: model.Logging,
queue: EntryQueue,
queries_dropped: std.atomic.Value(u64),
/// When the newest drop happened, in unix seconds; 0 means none yet. Read
/// through `lastDropSeconds`, which is what turns the sentinel into a null.
last_drop_s: std.atomic.Value(i64),
rows_written: std.atomic.Value(u64),
batches_gated: std.atomic.Value(u64),
/// The gating episode `/api/health` reports as `query_history.losing`, as
/// `Gate` bits. Moved only by `gateHolds`/`gateReopened`, which the writer
/// calls as it observes the monitor, and raised to `losing` by a drop that
/// carries the identity of the episode still holding.
///
/// Read it through `gateEpisode` or `sampleGate`, never as a raw integer.
gate: std.atomic.Value(u64),
/// Set when `runWriter` gives up before it consumed anything. The queue is
/// closed and every entry counts as dropped from that point, so a caller
/// that sees this must not expect rows.
@@ -363,8 +437,10 @@ pub const Logger = struct {
.cfg = cfg,
.queue = .init(queue_buf),
.queries_dropped = .init(0),
.last_drop_s = .init(0),
.rows_written = .init(0),
.batches_gated = .init(0),
.gate = .init(Gate.initial.bits()),
.writer_failed = .init(false),
.draining = .init(false),
};
@@ -422,10 +498,17 @@ pub const Logger = struct {
if (self.queue.capacity() == 0) break;
var oldest: [1]Entry = undefined;
discard_stall.park(io);
const got = self.queue.get(io, &oldest, 0) catch break;
if (got == 1) self.countDropped(1);
// Sampled after the eviction and not before the loop: the row is
// lost on the line above, and a sample taken while the gate was
// still open would let an episode that opened during `put` escape
// being marked for a row it really cost. Reading it here cannot
// misattribute in the other direction either — a sample the gate
// outruns fails `countDropped`'s generation check.
if (got == 1) self.countDropped(io, 1, self.sampleGate());
}
self.countDropped(1);
self.countDropped(io, 1, self.sampleGate());
}
/// The writer task: owns `database` and its prepared statements for its
@@ -453,7 +536,7 @@ pub const Logger = struct {
// life of the process, so the row stays active, which is the truth.
self.reportWrite(io, "writer", "preparing the batch statements failed", @errorName(err), 0);
self.queue.close(io);
_ = self.dropRemaining(io);
_ = self.dropRemaining(io, self.sampleGate());
return;
};
defer writer.deinit();
@@ -466,17 +549,24 @@ pub const Logger = struct {
error.Closed => return,
error.Canceled => |e| return e,
};
// Before `fill`, not only inside `flush`: `fill` spends the whole
// flush interval taking entries off the queue, and a producer that
// overflows the queue during that wait is losing rows to the gate
// just as surely as the held batch is. Observing here is what makes
// the episode start cover those drops instead of misfiling them as
// ordinary overflow.
const at = self.observeGate(monitor);
const deadline = self.flushDeadline(io);
// `n` is live across both calls: entries already taken off the
// queue are lost if either one is canceled, so they must count.
var n: usize = 1;
self.fill(io, &batch, deadline, &n) catch |err| {
self.countDropped(n);
self.fill(io, &batch, deadline, &n, at) catch |err| {
self.countDropped(io, n, at);
return err;
};
self.flush(io, &writer, batch[0..n], monitor) catch |err| switch (err) {
error.Canceled => |e| {
self.countDropped(n);
self.countDropped(io, n, at);
return e;
},
// Nothing will open the gate now. Everything still queued is
@@ -484,8 +574,12 @@ pub const Logger = struct {
// announced once — a per-chunk report would write to the very
// disk that is out of space, dozens of times, on the way out.
error.GatedAtShutdown => {
self.countDropped(n);
const lost = n + self.dropRemaining(io);
// Re-sampled rather than reusing `at`: `flush` observed the
// gate again on its way to this error, so the episode that
// is costing these rows is the one holding now.
const gated_at = self.sampleGate();
self.countDropped(io, n, gated_at);
const lost = n + self.dropRemaining(io, gated_at);
scope.warn(
"query log: {d} rows dropped at shutdown, the disk gate was closed",
.{lost},
@@ -521,7 +615,7 @@ pub const Logger = struct {
/// many. The drain is uncancelable: a cancellation racing the writer's own
/// failure would otherwise abandon the buffered entries without counting
/// them.
fn dropRemaining(self: *Logger, io: std.Io) usize {
fn dropRemaining(self: *Logger, io: std.Io, at: Gate) usize {
var total: usize = 0;
var leftover: [flush_batch]Entry = undefined;
while (true) {
@@ -529,7 +623,7 @@ pub const Logger = struct {
error.Closed => break,
};
if (n == 0) break;
self.countDropped(n);
self.countDropped(io, n, at);
total += n;
}
return total;
@@ -559,13 +653,14 @@ pub const Logger = struct {
batch: *[flush_batch]Entry,
deadline: std.Io.Clock.Timestamp,
n: *usize,
at: Gate,
) std.Io.Cancelable!void {
n.* += self.drainAvailable(io, batch[n.*..]);
while (n.* < batch.len) {
const remaining = deadline.durationFromNow(io);
if (remaining.raw.nanoseconds <= 0) break;
const entry = try self.getWithin(io, remaining) orelse break;
const entry = try self.getWithin(io, remaining, at) orelse break;
batch[n.*] = entry;
n.* += 1;
n.* += self.drainAvailable(io, batch[n.*..]);
@@ -588,6 +683,7 @@ pub const Logger = struct {
self: *Logger,
io: std.Io,
budget: std.Io.Clock.Duration,
at: Gate,
) std.Io.Cancelable!?Entry {
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
@@ -603,7 +699,7 @@ pub const Logger = struct {
const first = race.await() catch |err| {
// Teardown: the entry the getter already took has nowhere to go.
if (drainRace(&race)) |_| self.countDropped(1);
if (drainRace(&race)) |_| self.countDropped(io, 1, at);
return err;
};
const late = drainRace(&race);
@@ -628,6 +724,7 @@ pub const Logger = struct {
.clock = .awake,
};
while (!m.writesAllowed()) {
self.gateHolds();
// Waiting for the disk to recover is right while the process
// runs and wrong once it is stopping: nothing is going to free
// space during shutdown, so the batch is lost either way and
@@ -638,6 +735,7 @@ pub const Logger = struct {
_ = self.batches_gated.fetchAdd(1, .monotonic);
try pause.sleep(io);
}
self.gateReopened();
}
var rows: [flush_batch]queries_repo.Row = undefined;
@@ -645,7 +743,10 @@ pub const Logger = struct {
writer.writeBatch(rows[0..entries.len]) catch |err| {
scope.warn("query log batch of {d} rows dropped: {s}", .{ entries.len, @errorName(err) });
self.countDropped(entries.len);
// Sampled here, so a batch the gate already let through is counted
// against whatever episode is open now — a database failure is not
// a gating loss.
self.countDropped(io, entries.len, self.sampleGate());
self.reportWrite(io, "batch", "a query log batch was dropped", @errorName(err), entries.len);
return;
};
@@ -683,8 +784,92 @@ pub const Logger = struct {
);
}
fn countDropped(self: *Logger, n: usize) void {
/// Counts `n` lost rows, stamps the loss, and raises the episode `at` to
/// `losing` if that episode is still the current one.
///
/// `at` is sampled where the rows were lost, not here: see `Gate`. A caller
/// that lost rows outside any episode passes what `sampleGate` gave it and
/// the raise is simply a no-op.
///
/// The stamp is a second atomic rather than a field beside the count, so a
/// reader can briefly see the new total against the previous timestamp. The
/// health contract says so: `dropped_total` above zero with a null
/// `last_drop_s` is a legal, momentary answer.
fn countDropped(self: *Logger, io: std.Io, n: usize, at: Gate) void {
_ = self.queries_dropped.fetchAdd(n, .monotonic);
self.stampDrop(std.Io.Clock.real.now(io).toSeconds());
if (at.episode != .gated) return;
const losing: Gate = .{ .episode = .losing, .generation = at.generation };
_ = self.gate.cmpxchgStrong(at.bits(), losing.bits(), .acq_rel, .monotonic);
}
/// Moves the stamp forward only. Two producers can reach `countDropped` out
/// of order, and a plain store would let the older one publish its
/// timestamp over the newer drop's — a `last_drop_s` that walks backwards
/// while drops are still arriving.
fn stampDrop(self: *Logger, at_s: i64) void {
var seen = self.last_drop_s.load(.monotonic);
while (at_s > seen) {
seen = self.last_drop_s.cmpxchgWeak(seen, at_s, .monotonic, .monotonic) orelse return;
}
}
/// When the newest drop happened, or null while nothing has been dropped.
pub fn lastDropSeconds(self: *const Logger) ?i64 {
const stamped = self.last_drop_s.load(.monotonic);
return if (stamped == 0) null else stamped;
}
/// Where the gate stands right now, for a reader that only wants the state.
pub fn gateEpisode(self: *const Logger) GateEpisode {
return Gate.of(self.gate.load(.acquire)).episode;
}
/// The identity a caller must carry with rows it loses.
///
/// Take it where the loss actually happens. A producer discarding one entry
/// samples at the discard; the writer samples once at `observeGate`,
/// because the batch it is holding belongs to the episode that was open
/// while it filled. Sampling earlier than the loss hides episodes that
/// opened in between; sampling later cannot misattribute, because the
/// generation makes an outrun sample fail its swap.
fn sampleGate(self: *const Logger) Gate {
return Gate.of(self.gate.load(.acquire));
}
/// Opens a gating episode under a fresh generation, or leaves one that is
/// already holding alone — `losing` must not fall back to `gated`, and a
/// second observation of the same gate must not look like a new episode.
fn gateHolds(self: *Logger) void {
var current = self.sampleGate();
while (current.episode == .open) {
const next: Gate = .{ .episode = .gated, .generation = current.generation +% 1 };
const raced = self.gate.cmpxchgWeak(current.bits(), next.bits(), .acq_rel, .acquire) orelse
return;
current = Gate.of(raced);
}
}
/// Ends the episode, keeping its generation so the next one gets a number
/// no stale producer holds. A drop sampled during the episode that lands
/// after this reopen finds its generation gone and is discarded.
fn gateReopened(self: *Logger) void {
var current = self.sampleGate();
while (current.episode != .open) {
const next: Gate = .{ .episode = .open, .generation = current.generation };
const raced = self.gate.cmpxchgWeak(current.bits(), next.bits(), .acq_rel, .acquire) orelse
return;
current = Gate.of(raced);
}
}
/// Moves the episode to wherever the monitor says the gate is, and returns
/// the episode the caller's rows now belong to. A null monitor is no gating
/// at all, so the episode stays `open`.
fn observeGate(self: *Logger, monitor: ?*disk_monitor.Monitor) Gate {
const m = monitor orelse return self.sampleGate();
if (m.writesAllowed()) self.gateReopened() else self.gateHolds();
return self.sampleGate();
}
};
@@ -1189,7 +1374,7 @@ test "entries that arrive inside one window reach the database in one batch" {
var batch: [flush_batch]Entry = undefined;
batch[0] = try logger.queue.getOne(io);
var n: usize = 1;
try logger.fill(io, &batch, logger.flushDeadline(io), &n);
try logger.fill(io, &batch, logger.flushDeadline(io), &n, logger.sampleGate());
try testing.expectEqual(@as(usize, 6), n);
// One `writeBatch` call, which is one transaction (`queries_repo.zig`).
@@ -1214,7 +1399,7 @@ test "a zero interval takes what is queued and waits for nothing" {
var n: usize = 1;
// The queue is open and the batch has room: any non-zero interval blocks
// here until it expires. Zero returns with what was already queued.
try logger.fill(io, &batch, logger.flushDeadline(io), &n);
try logger.fill(io, &batch, logger.flushDeadline(io), &n, logger.sampleGate());
try testing.expectEqual(@as(usize, 2), n);
}
@@ -1404,6 +1589,189 @@ test "a writer that cannot prepare closes the queue and counts every entry" {
try testing.expectEqual(@as(u64, 4), logger.queries_dropped.load(.monotonic));
}
test "a queue overflow stamps the drop and leaves the gate episode open" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// No writer and no monitor: nothing consumes the queue, so the third entry
// has to displace the oldest, and no gate is involved in the loss.
var buf: [2]Entry = undefined;
var logger: Logger = .init(.{}, &buf);
try testing.expectEqual(@as(?i64, null), logger.lastDropSeconds());
for (0..3) |i| logger.log(io, sampleEntry(@intCast(i), "overflow.example"));
try testing.expectEqual(@as(u64, 1), logger.queries_dropped.load(.monotonic));
const stamped = logger.lastDropSeconds() orelse return error.TestUnexpectedResult;
try testing.expect(stamped > 1_700_000_000);
// Cumulative loss is not a current fault: the episode never opened.
try testing.expectEqual(GateEpisode.open, logger.gateEpisode());
}
test "the gating episode opens on the gate, turns losing on a drop, and clears on recovery" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
// Small enough that the entries logged below have to displace each other,
// which is the gate-caused overflow this episode is meant to catch.
var buf: [4]Entry = undefined;
var logger: Logger = .init(.{ .query_log_flush_interval_s = 0 }, &buf);
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
logger.log(io, sampleEntry(1, "held.example"));
var future = try io.concurrent(Logger.runWriter, .{
&logger,
io,
&database,
@as(?*disk_monitor.Monitor, &monitor),
});
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
var waited: usize = 0;
while (logger.batches_gated.load(.monotonic) == 0) : (waited += 1) {
try testing.expect(waited < 400);
try poll.sleep(io);
}
// The gate is holding and nothing has been lost yet, which is not a fault:
// the batch is still going to be written if the disk recovers.
try testing.expectEqual(GateEpisode.gated, logger.gateEpisode());
try testing.expectEqual(@as(u64, 0), logger.queries_dropped.load(.monotonic));
// Now overflow the queue behind the held batch. These drops belong to the
// episode, and that is what turns it from held to losing.
for (0..32) |i| logger.log(io, sampleEntry(@intCast(i + 2), "queued.example"));
try testing.expect(logger.queries_dropped.load(.monotonic) > 0);
try testing.expectEqual(GateEpisode.losing, logger.gateEpisode());
try testing.expect(logger.lastDropSeconds() != null);
// The disk recovers: the held batch goes out and the episode ends.
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
waited = 0;
while (logger.gateEpisode() != .open) : (waited += 1) {
try testing.expect(waited < 400);
try poll.sleep(io);
}
try testing.expect(logger.rows_written.load(.monotonic) > 0);
// The count keeps the history the state does not.
try testing.expect(logger.queries_dropped.load(.monotonic) > 0);
logger.shutdown(io);
try future.await(io);
}
// The two tests below drive the gate primitives directly. A threaded test
// cannot prove the absence of the race they close — it can only fail to hit it
// — so they pin the mechanism instead: the generation a drop must match, and
// the direction the stamp may move.
test "a drop sampled in one episode cannot mark the next one losing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var queue_buf: [2]Entry = undefined;
var logger: Logger = .init(.{}, &queue_buf);
logger.gateHolds();
// What a producer holds while it stalls: episode one, still holding.
const stalled_in = logger.sampleGate();
try testing.expectEqual(GateEpisode.gated, stalled_in.episode);
// The gate opens and closes again while that producer is descheduled.
logger.gateReopened();
logger.gateHolds();
const episode_two = logger.sampleGate();
try testing.expectEqual(GateEpisode.gated, episode_two.episode);
try testing.expect(episode_two.generation != stalled_in.generation);
// The stale drop still counts as a lost row — it was one — but episode two
// has cost nothing and must not be told it has.
logger.countDropped(io, 1, stalled_in);
try testing.expectEqual(@as(u64, 1), logger.queries_dropped.load(.monotonic));
try testing.expectEqual(GateEpisode.gated, logger.gateEpisode());
// A drop that really belongs to episode two does raise it.
logger.countDropped(io, 1, episode_two);
try testing.expectEqual(GateEpisode.losing, logger.gateEpisode());
// And a second hold of a gate that never opened is the same episode, not a
// new one: `losing` must not fall back to `gated`.
logger.gateHolds();
try testing.expectEqual(GateEpisode.losing, logger.gateEpisode());
try testing.expectEqual(episode_two.generation, logger.sampleGate().generation);
}
fn logOne(logger: *Logger, io: std.Io) void {
logger.log(io, sampleEntry(2, "second.example"));
}
test "a gate that closes mid-enqueue still gets the discard that follows it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// Capacity one and no writer, so the second entry has to evict the first
// and the producer discards inside `enqueue`.
var queue_buf: [1]Entry = undefined;
var logger: Logger = .init(.{}, &queue_buf);
logger.log(io, sampleEntry(1, "first.example"));
try testing.expectEqual(GateEpisode.open, logger.gateEpisode());
discard_stall.parked = .unset;
discard_stall.release = .unset;
discard_stall.armed = true;
defer discard_stall.armed = false;
// The producer enters `enqueue` with the gate open — which is the reading a
// sample taken before the loop would keep for the rest of the call.
var producer = try io.concurrent(logOne, .{ &logger, io });
discard_stall.parked.waitUncancelable(io);
// The producer is parked with the row it will evict still on the queue, so
// the episode opens strictly before the loss rather than after it. Nothing
// has been dropped yet, and that is what makes the row this episode's.
try testing.expectEqual(@as(u64, 0), logger.queries_dropped.load(.monotonic));
logger.gateHolds();
discard_stall.armed = false;
discard_stall.release.set(io);
producer.await(io);
try testing.expectEqual(@as(u64, 1), logger.queries_dropped.load(.monotonic));
// The assertion the pre-loop sample fails: it would still be holding
// `open`, `countDropped` would return before its swap, and the episode
// would sit at `gated` having silently cost a row.
try testing.expectEqual(GateEpisode.losing, logger.gateEpisode());
}
test "the drop stamp only ever moves forward" {
var queue_buf: [2]Entry = undefined;
var logger: Logger = .init(.{}, &queue_buf);
try testing.expectEqual(@as(?i64, null), logger.lastDropSeconds());
logger.stampDrop(1_700_000_100);
try testing.expectEqual(@as(?i64, 1_700_000_100), logger.lastDropSeconds());
// A producer that read its clock earlier but arrives later: the newer drop
// already published its time and must keep it.
logger.stampDrop(1_700_000_050);
try testing.expectEqual(@as(?i64, 1_700_000_100), logger.lastDropSeconds());
logger.stampDrop(1_700_000_200);
try testing.expectEqual(@as(?i64, 1_700_000_200), logger.lastDropSeconds());
}
test "a canceled writer counts the batch it was holding" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+6 -26
View File
@@ -71,24 +71,6 @@ pub const ddl: [:0]const u8 =
\\CREATE INDEX idx_query_log_client ON query_log(client_ip);
\\CREATE INDEX idx_query_log_domain ON query_log(domain_id);
\\
\\CREATE TABLE upstream_targets (
\\ id INTEGER PRIMARY KEY,
\\ url TEXT NOT NULL UNIQUE -- the historical identity: config.db ids cannot cross database files
\\);
\\
\\CREATE TABLE upstream_minute (
\\ upstream_id INTEGER NOT NULL REFERENCES upstream_targets(id),
\\ minute_ts INTEGER NOT NULL,
\\ successes INTEGER NOT NULL,
\\ failures INTEGER NOT NULL,
\\ last_failure_ts INTEGER,
\\ last_error TEXT,
\\ PRIMARY KEY (upstream_id, minute_ts),
\\ CHECK (successes >= 0),
\\ CHECK (failures >= 0)
\\) WITHOUT ROWID;
\\CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
\\
\\CREATE TABLE querylog_meta (
\\ id INTEGER PRIMARY KEY CHECK (id = 1), -- one row, enforced by the schema
\\ created_at INTEGER NOT NULL,
@@ -121,9 +103,9 @@ const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{f
///
/// - Commit never fsyncs at `synchronous = NORMAL`; the checkpoint's fsync is
/// the only guaranteed durability boundary. This moves that boundary from
/// ~40 min to ~5 h of querylog data (query rows plus upstream-history
/// minutes) under power loss or kernel panic. Typical loss stays far smaller
/// because of kernel writeback, but that is not a guarantee.
/// ~40 min to ~5 h of querylog data under power loss or kernel panic.
/// Typical loss stays far smaller because of kernel writeback, but that is
/// not a guarantee.
/// - Process crash or clean stop loses nothing committed, at any threshold.
/// Consistency is never at risk: recovery replays the longest valid WAL
/// prefix atomically.
@@ -325,22 +307,20 @@ test "fingerprint matches a fresh hash of the DDL" {
try testing.expectEqual(fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(ddl))));
}
test "ddl creates the query-log tables, the upstream-history tables and every index" {
test "ddl creates the query-log tables and every index" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
try database.exec(ddl);
try testing.expectEqual(
@as(i64, 5),
@as(i64, 3),
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
);
const objects = [_][]const u8{
"domains", "query_log",
"idx_query_log_ts", "idx_query_log_client",
"idx_query_log_domain", "upstream_targets",
"upstream_minute", "idx_upstream_minute_ts",
"querylog_meta",
"idx_query_log_domain", "querylog_meta",
};
for (objects) |name| {
var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
+18
View File
@@ -172,6 +172,24 @@ pub fn resolveActiveByKey(
return database.changes() != 0;
}
const resolve_by_code_sql =
\\UPDATE operational_events SET resolved_at = ?2
\\ WHERE code = ?1 AND resolved_at IS NULL
;
/// Closes every open episode of `code`, and returns how many. The store uses it
/// once at init for a code no emitter writes any more: an episode whose producer
/// no longer exists can never demonstrate recovery, so nothing but this would
/// ever close it.
pub fn resolveActiveByCode(database: *db.Db, now_s: i64, code: []const u8) db.Error!i64 {
var stmt = try database.prepare(resolve_by_code_sql);
defer stmt.deinit();
try stmt.bindText(1, code);
try stmt.bindInt(2, now_s);
try stmt.exec();
return database.changes();
}
const select_active_id_sql =
"SELECT id FROM operational_events WHERE code = ?1 AND subject_key = ?2 AND resolved_at IS NULL";
+541 -8
View File
@@ -547,7 +547,6 @@ fn likePattern(arena: Allocator, needle: []const u8) Allocator.Error![]const u8
pub const StatsTotals = struct {
queries: u64,
blocked: u64,
cached: u64,
distinct_clients: u64,
avg_response_time_us: ?i64,
};
@@ -557,7 +556,6 @@ pub const StatsTotals = struct {
const stats_totals_sql =
\\SELECT count(*),
\\ coalesce(sum(blocked <> 0), 0),
\\ coalesce(sum(cache_hit = 1), 0),
\\ count(DISTINCT client_ip),
\\ coalesce(sum(response_time_us), 0),
\\ count(response_time_us)
@@ -577,13 +575,12 @@ pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotal
// statement is not the one this function prepared.
if (!try stmt.step()) return error.Misuse;
const timed = stmt.columnInt(5);
const timed = stmt.columnInt(4);
return .{
.queries = try countOf(stmt.columnInt(0)),
.blocked = try countOf(stmt.columnInt(1)),
.cached = try countOf(stmt.columnInt(2)),
.distinct_clients = try countOf(stmt.columnInt(3)),
.avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(4), timed),
.distinct_clients = try countOf(stmt.columnInt(2)),
.avg_response_time_us = if (timed == 0) null else @divTrunc(stmt.columnInt(3), timed),
};
}
@@ -653,10 +650,217 @@ pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Buck
return out.len;
}
/// One row of `/api/stats/types`. `qtype` is nullable in the schema, so the
/// rows that carry no type group into a row of their own rather than
/// disappearing from a breakdown that claims to add up.
///
/// No name field: the only qtype-name table lives in the admin, and a second
/// copy here would drift out of agreement with it.
pub const TypeCount = struct {
qtype: ?u16,
count: u64,
};
/// `qtype IS NULL` sorts 0 before 1, which puts the null row last within a tie.
/// The ordering is total, so two reads of one window return the same list in
/// the same order — which is what makes the goldens byte-stable. It is an
/// order, not an identity: a caller keys on the `qtype` value, never on a row's
/// position, because a rank change between refreshes moves rows and must not
/// move what they mean.
const stats_types_sql =
\\SELECT qtype, count(*)
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?2
\\ GROUP BY qtype
\\ ORDER BY count(*) DESC, qtype IS NULL, qtype ASC
;
/// The query-type breakdown of `[since, until)`. No zero rows: a type absent
/// from the window is absent from the list.
pub fn statsTypes(
database: *db.Db,
arena: Allocator,
since: i64,
until: i64,
) db.Error!std.ArrayList(TypeCount) {
var out: std.ArrayList(TypeCount) = .empty;
var stmt = try database.prepare(stats_types_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, until);
while (try stmt.step()) {
try out.append(arena, .{
.qtype = if (stmt.isNull(0)) null else try columnU16(&stmt, 0),
.count = try countOf(stmt.columnInt(1)),
});
}
return out;
}
/// One row of `/api/stats/routes`: how a slice of the window was answered.
///
/// `source` is the answering resolver's identity and nothing else — the
/// upstream url for `upstream` rows, the zone for `forward_zone` rows, null
/// everywhere else. It is deliberately not `source_name`, which names the
/// blocklist a block came from and would read as an upstream here.
pub const RouteCount = struct {
route: provenance.RouteKind,
source: ?[]const u8,
count: u64,
};
/// A null upstream on an `upstream` row is its own group, not a dropped row: it
/// is a real state of the log and the caller labels it.
const stats_routes_sql =
\\SELECT route_kind,
\\ CASE route_kind
\\ WHEN 'upstream' THEN upstream
\\ WHEN 'forward_zone' THEN forward_zone
\\ END AS source,
\\ count(*)
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?2
\\ GROUP BY route_kind, source
\\ ORDER BY count(*) DESC, route_kind ASC, source IS NULL, source ASC
;
/// The answering-route breakdown of `[since, until)`. Strings are copied into
/// `arena`, which outlives the statement.
pub fn statsRoutes(
database: *db.Db,
arena: Allocator,
since: i64,
until: i64,
) db.Error!std.ArrayList(RouteCount) {
var out: std.ArrayList(RouteCount) = .empty;
var stmt = try database.prepare(stats_routes_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, until);
while (try stmt.step()) {
try out.append(arena, .{
.route = try provenance.parse(provenance.RouteKind, stmt.columnText(0)),
.source = try stmt.columnTextAllocOrNull(arena, 1),
.count = try countOf(stmt.columnInt(2)),
});
}
return out;
}
/// How many clients `/api/stats/clients` names before the rest become `other`.
/// Eight is what one legend can carry without becoming a second table.
pub const max_client_series = 8;
/// One named client's series. `buckets` is always the caller's bucket count
/// long, zero-filled, and aligned exactly like `timeseries`.
pub const ClientSeries = struct {
client: []const u8,
buckets: []const u64,
};
/// `other` is always present and always bucket-count sized — including for an
/// empty window and for a window with eight clients or fewer. A caller charting
/// a stack must not have to invent the residual series.
pub const ClientsBreakdown = struct {
clients: []const ClientSeries,
other: []const u64,
};
/// Ranked by in-window total, ties broken by address, so the cut at eight is
/// the same cut on every request over the same data.
const stats_clients_rank_sql =
\\SELECT client_ip
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?2
\\ GROUP BY client_ip
\\ ORDER BY count(*) DESC, client_ip ASC
\\ LIMIT ?3
;
const stats_clients_buckets_sql =
\\SELECT client_ip, (timestamp - ?1) / ?2, count(*)
\\ FROM query_log
\\ WHERE timestamp >= ?1 AND timestamp < ?3
\\ GROUP BY 1, 2
;
/// Per-client counts over `bucket_count` buckets of `bucket_seconds` starting
/// at `since`. Everything outside the top `max_client_series` sums into
/// `other`, so the series still add up to the window's total.
///
/// Two statements, one ranking and one bucketing: the caller runs them inside
/// one read transaction, so the rank and the buckets describe one state.
pub fn statsClients(
database: *db.Db,
arena: Allocator,
since: i64,
bucket_seconds: u32,
bucket_count: u32,
) db.Error!ClientsBreakdown {
if (bucket_seconds == 0 or bucket_count == 0) return error.Misuse;
const width: i64 = bucket_seconds;
const span = std.math.mul(i64, width, bucket_count) catch return error.Misuse;
const until = std.math.add(i64, since, span) catch return error.Misuse;
var names: std.ArrayList([]const u8) = .empty;
var series: std.ArrayList([]u64) = .empty;
{
var stmt = try database.prepare(stats_clients_rank_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, until);
try stmt.bindInt(3, max_client_series);
while (try stmt.step()) {
try names.append(arena, try stmt.columnTextAlloc(arena, 0));
try series.append(arena, try zeroedBuckets(arena, bucket_count));
}
}
const other = try zeroedBuckets(arena, bucket_count);
var stmt = try database.prepare(stats_clients_buckets_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, width);
try stmt.bindInt(3, until);
while (try stmt.step()) {
// The WHERE clause bounds the index already; the check keeps a schema
// surprise from writing past the slice.
const index = std.math.cast(usize, stmt.columnInt(1)) orelse return error.Mismatch;
if (index >= bucket_count) return error.Mismatch;
const count = try countOf(stmt.columnInt(2));
const client = stmt.columnText(0);
const target = for (names.items, series.items) |name_, buckets| {
if (std.mem.eql(u8, name_, client)) break buckets;
} else other;
target[index] += count;
}
const clients = try arena.alloc(ClientSeries, names.items.len);
for (clients, names.items, series.items) |*entry, name_, buckets| {
entry.* = .{ .client = name_, .buckets = buckets };
}
return .{ .clients = clients, .other = other };
}
fn zeroedBuckets(arena: Allocator, bucket_count: u32) Allocator.Error![]u64 {
const buckets = try arena.alloc(u64, bucket_count);
@memset(buckets, 0);
return buckets;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const logger = @import("../logger.zig");
const querylog_schema = @import("../querylog_schema.zig");
const testing = std.testing;
@@ -1469,7 +1673,6 @@ test "statsTotals aggregates the window and averages only the timed rows" {
const totals = try statsTotals(&database, 100, 200);
try testing.expectEqual(@as(u64, 3), totals.queries);
try testing.expectEqual(@as(u64, 1), totals.blocked);
try testing.expectEqual(@as(u64, 1), totals.cached);
try testing.expectEqual(@as(u64, 2), totals.distinct_clients);
// (100 + 200) / 2 — the untimed row is not in the divisor.
try testing.expectEqual(@as(?i64, 150), totals.avg_response_time_us);
@@ -1484,7 +1687,6 @@ test "statsTotals over an empty window is zeros with a null average" {
const totals = try statsTotals(&database, window[0], window[1]);
try testing.expectEqual(@as(u64, 0), totals.queries);
try testing.expectEqual(@as(u64, 0), totals.blocked);
try testing.expectEqual(@as(u64, 0), totals.cached);
try testing.expectEqual(@as(u64, 0), totals.distinct_clients);
try testing.expectEqual(@as(?i64, null), totals.avg_response_time_us);
}
@@ -1588,3 +1790,334 @@ test "likePattern wraps the needle and neutralises every metacharacter" {
try testing.expectEqualStrings("%a\\\\b%", try likePattern(arena, "a\\b"));
try testing.expectEqualStrings("%%", try likePattern(arena, ""));
}
// ---------------------------------------------------------------------------
// period aggregations (milestone 30)
// ---------------------------------------------------------------------------
const agg_since: i64 = 1_700_000_000;
const agg_width: u32 = 60;
const agg_buckets: u32 = 10;
const agg_until: i64 = agg_since + agg_width * agg_buckets;
/// One row of the aggregation fixtures. Everything the three breakdowns read
/// is a parameter; everything else is the same on every row, so a test that
/// changes an outcome names the reason it changed.
fn aggRow(offset: i64, client: []const u8, qtype: ?u16, kind: provenance.RouteKind, source: ?[]const u8) Row {
return .{
.timestamp = agg_since + offset,
.domain = "example.com",
.client_ip = client,
.qtype = qtype,
.qclass = 1,
.rcode = 0,
.blocked = kind == .blocked,
.response_time_us = 1000,
.cache_hit = kind == .cache,
.upstream = if (kind == .upstream) source else null,
.group_id = 1,
.group_name = "default",
.policy_action = if (kind == .blocked) .block else .allow,
.policy_reason = if (kind == .blocked) .blocklist_domain else .no_match,
.matched = null,
.source_id = null,
// Blocklist provenance, deliberately set on every row: the routes
// breakdown must never group by it.
.source_name = "StevenBlack",
.cname_target = null,
.safe_search_target = null,
.route_kind = kind,
.forward_zone = if (kind == .forward_zone) source else null,
};
}
test "the type breakdown groups by qtype, keeps the null row and orders it last" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try seed(&database, &.{
aggRow(0, "192.0.2.10", 1, .upstream, "9.9.9.9"),
aggRow(1, "192.0.2.10", 1, .upstream, "9.9.9.9"),
aggRow(2, "192.0.2.10", 1, .upstream, "9.9.9.9"),
aggRow(3, "192.0.2.10", 28, .upstream, "9.9.9.9"),
aggRow(4, "192.0.2.10", 28, .upstream, "9.9.9.9"),
// Ties with qtype 28, so the tie-break puts the lower code first.
aggRow(5, "192.0.2.10", 16, .upstream, "9.9.9.9"),
aggRow(6, "192.0.2.10", 16, .upstream, "9.9.9.9"),
// A row with no type at all: its own group, never a dropped row.
aggRow(7, "192.0.2.10", null, .upstream, "9.9.9.9"),
aggRow(8, "192.0.2.10", null, .upstream, "9.9.9.9"),
// Outside the window.
aggRow(-1, "192.0.2.10", 255, .upstream, "9.9.9.9"),
});
const rows = (try statsTypes(&database, arena, agg_since, agg_until)).items;
try testing.expectEqual(@as(usize, 4), rows.len);
try testing.expectEqual(@as(?u16, 1), rows[0].qtype);
try testing.expectEqual(@as(u64, 3), rows[0].count);
try testing.expectEqual(@as(?u16, 16), rows[1].qtype);
try testing.expectEqual(@as(?u16, 28), rows[2].qtype);
try testing.expectEqual(@as(?u16, null), rows[3].qtype);
try testing.expectEqual(@as(u64, 2), rows[3].count);
}
test "an empty window has no type rows at all" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const rows = (try statsTypes(&database, arena_state.allocator(), agg_since, agg_until)).items;
try testing.expectEqual(@as(usize, 0), rows.len);
}
test "the route breakdown keys on the answering resolver, not on blocklist provenance" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try seed(&database, &.{
aggRow(0, "192.0.2.10", 1, .upstream, "https://a.example/dns-query"),
aggRow(1, "192.0.2.10", 1, .upstream, "https://a.example/dns-query"),
aggRow(2, "192.0.2.10", 1, .upstream, "https://b.example/dns-query"),
// An upstream row whose resolver the log did not record: its own group.
aggRow(3, "192.0.2.10", 1, .upstream, null),
aggRow(4, "192.0.2.10", 1, .forward_zone, "lan"),
aggRow(5, "192.0.2.10", 1, .blocked, null),
aggRow(6, "192.0.2.10", 1, .cache, null),
aggRow(7, "192.0.2.10", 1, .local, null),
aggRow(8, "192.0.2.10", 1, .rejected, null),
});
const rows = (try statsRoutes(&database, arena, agg_since, agg_until)).items;
// Two upstreams, one null-source upstream, one forward zone and four
// source-less kinds. Every row carries the same `source_name`, so a
// breakdown that grouped by it would collapse to one row.
try testing.expectEqual(@as(usize, 8), rows.len);
try testing.expectEqual(provenance.RouteKind.upstream, rows[0].route);
try testing.expectEqualStrings("https://a.example/dns-query", rows[0].source.?);
try testing.expectEqual(@as(u64, 2), rows[0].count);
// The seven remaining rows all count 1, so the tie-break orders them:
// route ascending, then source ascending with nulls last.
for (rows[1..]) |row| try testing.expectEqual(@as(u64, 1), row.count);
try testing.expectEqual(provenance.RouteKind.blocked, rows[1].route);
try testing.expectEqual(@as(?[]const u8, null), rows[1].source);
try testing.expectEqual(provenance.RouteKind.cache, rows[2].route);
try testing.expectEqual(provenance.RouteKind.forward_zone, rows[3].route);
try testing.expectEqualStrings("lan", rows[3].source.?);
try testing.expectEqual(provenance.RouteKind.local, rows[4].route);
try testing.expectEqual(provenance.RouteKind.rejected, rows[5].route);
try testing.expectEqual(provenance.RouteKind.upstream, rows[6].route);
try testing.expectEqualStrings("https://b.example/dns-query", rows[6].source.?);
try testing.expectEqual(provenance.RouteKind.upstream, rows[7].route);
try testing.expectEqual(@as(?[]const u8, null), rows[7].source);
}
test "an empty window has no route rows at all" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const rows = (try statsRoutes(&database, arena_state.allocator(), agg_since, agg_until)).items;
try testing.expectEqual(@as(usize, 0), rows.len);
}
test "an empty window still has a zero-filled other series and no named clients" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const result = try statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
try testing.expectEqual(@as(usize, 0), result.clients.len);
try testing.expectEqual(@as(usize, agg_buckets), result.other.len);
for (result.other) |count| try testing.expectEqual(@as(u64, 0), count);
}
test "client series are bucket-aligned, zero-filled and ranked by in-window total" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try seed(&database, &.{
aggRow(0, "192.0.2.20", 1, .upstream, "9.9.9.9"),
aggRow(1, "192.0.2.20", 1, .upstream, "9.9.9.9"),
aggRow(agg_width * 3, "192.0.2.20", 1, .upstream, "9.9.9.9"),
aggRow(agg_width * 3, "192.0.2.10", 1, .upstream, "9.9.9.9"),
// Outside the window on both sides.
aggRow(-1, "192.0.2.20", 1, .upstream, "9.9.9.9"),
aggRow(agg_width * agg_buckets, "192.0.2.10", 1, .upstream, "9.9.9.9"),
});
const result = try statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
try testing.expectEqual(@as(usize, 2), result.clients.len);
try testing.expectEqualStrings("192.0.2.20", result.clients[0].client);
try testing.expectEqualStrings("192.0.2.10", result.clients[1].client);
for (result.clients) |series| try testing.expectEqual(@as(usize, agg_buckets), series.buckets.len);
try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]);
try testing.expectEqual(@as(u64, 1), result.clients[0].buckets[3]);
try testing.expectEqual(@as(u64, 0), result.clients[0].buckets[9]);
try testing.expectEqual(@as(u64, 1), result.clients[1].buckets[3]);
for (result.other) |count| try testing.expectEqual(@as(u64, 0), count);
}
test "the ninth client folds into other and the cut is the same on every read" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Nine clients, each with one more query than the next, so the ranking is
// total and the ninth is unambiguously the one that folds.
var address: [16]u8 = undefined;
var index: u32 = 0;
while (index < 9) : (index += 1) {
const client = try std.fmt.bufPrint(&address, "192.0.2.{d}", .{100 + index});
var repeat: u32 = 0;
while (repeat <= index) : (repeat += 1) {
try seed(&database, &.{aggRow(@intCast(repeat), client, 1, .upstream, "9.9.9.9")});
}
}
const result = try statsClients(&database, arena, agg_since, agg_width, agg_buckets);
try testing.expectEqual(@as(usize, max_client_series), result.clients.len);
// The busiest is 192.0.2.108 with nine rows; the lone folded client is
// 192.0.2.100 with one.
try testing.expectEqualStrings("192.0.2.108", result.clients[0].client);
for (result.clients) |series| {
try testing.expect(!std.mem.eql(u8, "192.0.2.100", series.client));
}
var other_total: u64 = 0;
for (result.other) |count| other_total += count;
try testing.expectEqual(@as(u64, 1), other_total);
}
test "the three breakdowns conserve the window's total" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// A matrix that exercises every branch the aggregations partition on: null
// and non-null qtypes, every route kind, a null resolver identity, and ten
// clients so the top-eight cut has a residual to carry.
const kinds = [_]provenance.RouteKind{ .blocked, .local, .forward_zone, .upstream, .cache, .rejected };
var address: [16]u8 = undefined;
var index: u32 = 0;
while (index < 40) : (index += 1) {
const client = try std.fmt.bufPrint(&address, "192.0.2.{d}", .{100 + index % 10});
const kind = kinds[index % kinds.len];
try seed(&database, &.{aggRow(
@intCast(index % (agg_width * agg_buckets)),
client,
if (index % 7 == 0) null else @intCast(1 + index % 3),
kind,
if (index % 11 == 0) null else "9.9.9.9",
)});
}
const totals = try statsTotals(&database, agg_since, agg_until);
try testing.expect(totals.queries > 0);
var typed: u64 = 0;
for ((try statsTypes(&database, arena, agg_since, agg_until)).items) |row| typed += row.count;
try testing.expectEqual(totals.queries, typed);
var routed: u64 = 0;
for ((try statsRoutes(&database, arena, agg_since, agg_until)).items) |row| routed += row.count;
try testing.expectEqual(totals.queries, routed);
var buckets: [agg_buckets]Bucket = undefined;
_ = try timeseries(&database, agg_since, agg_width, &buckets);
const clients = try statsClients(&database, arena, agg_since, agg_width, agg_buckets);
// Per bucket, not just in total: a series misaligned by one bucket would
// still sum correctly over the window.
for (buckets, 0..) |bucket, at| {
var summed: u64 = clients.other[at];
for (clients.clients) |series| summed += series.buckets[at];
try testing.expectEqual(bucket.queries, summed);
}
}
test "the aggregations pass a redacted client through as the log stored it" {
var database = try openLog();
defer database.close();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// `hide_client_ips` is applied by the logger before the row is written, so
// the read path has nothing to transform — and must not invent anything
// either. The marker is the client's whole identity here.
try seed(&database, &.{
aggRow(0, logger.hidden_marker, 1, .upstream, "9.9.9.9"),
aggRow(1, logger.hidden_marker, 1, .upstream, "9.9.9.9"),
});
const result = try statsClients(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets);
try testing.expectEqual(@as(usize, 1), result.clients.len);
try testing.expectEqualStrings(logger.hidden_marker, result.clients[0].client);
try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]);
}
test "a prune committed mid-read is invisible to the reader's transaction" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/querylog.db", .{ tmp_prefix, &tmp.sub_path });
var reader = try db.Db.open(path, .{ .mode = .read_write_create });
defer reader.close();
try db.applyPragmas(&reader, .{});
try reader.exec(querylog_schema.ddl);
// The seeded watermark is the file's creation second, which is now; the
// fixtures below are in 2023, so the prune's cutoff would never advance it.
try reader.exec(
\\UPDATE querylog_meta SET created_at = 1700000000, available_since = 1700000000 WHERE id = 1
);
try seed(&reader, &.{
plainRow(agg_since, "a.example"),
plainRow(agg_since + 1, "b.example"),
plainRow(agg_since + 300, "c.example"),
});
// A second connection to the same file, as retention has in production.
var pruner = try db.Db.open(path, .{ .mode = .read_write_create });
defer pruner.close();
try db.applyPragmas(&pruner, .{});
var tx = try db.ReadTx.begin(&reader);
const before = try statsTotals(&reader, agg_since, agg_since + 1000);
const watermark_before = try availableSince(&reader);
try testing.expectEqual(@as(u64, 3), before.queries);
// The prune commits while the reader's transaction is open.
const pruned = try pruneOlderThan(&pruner, agg_since + 200);
try testing.expectEqual(@as(i64, 2), pruned.deleted);
try testing.expect(pruned.available_since > watermark_before);
// Neither half of the answer moved: the rows the reader would report and
// the watermark it would tag them with still describe one state.
const during = try statsTotals(&reader, agg_since, agg_since + 1000);
try testing.expectEqual(before.queries, during.queries);
try testing.expectEqual(watermark_before, try availableSince(&reader));
try tx.commit();
// The next response sees the prune — both halves of it.
const after = try statsTotals(&reader, agg_since, agg_since + 1000);
try testing.expectEqual(@as(u64, 1), after.queries);
try testing.expectEqual(pruned.available_since, try availableSince(&reader));
}
@@ -1,493 +0,0 @@
//! `upstream_minute` and its `upstream_targets` dimension table in
//! `querylog.db` (milestone-26 rulings 2, 4, 5).
//!
//! One row per upstream per wall-clock UTC minute that had at least one
//! attempt. Rows are additive facts: a flush adds to whatever is already there,
//! so a restart inside a minute continues that minute's row rather than
//! replacing it, and nothing here can lower a stored count.
//!
//! Identity is the url, not the `config.db` upstream id: ids cannot be foreign
//! keys across database files and may be deleted or reused. Editing an
//! upstream's url deliberately starts a new history.
//!
//! Nothing here retries. The accumulator owns what a failed flush means
//! (`upstream/history.zig`).
const std = @import("std");
const db = @import("../db.zig");
const health = @import("../../upstream/health.zig");
/// How far back `Retention` keeps minute rows. A fixed window, not a knob
/// (m26 anti-requirements), and deliberately wider than the widest dashboard
/// period: `stats.window` derives `since = until - width * count` with
/// `until > now`, so a 30-day window never asks for anything older than
/// `now - 30d`. The extra day is slack for a retention pass that runs late.
///
/// `logging.retention_days` does not apply here. It bounds the query log, whose
/// rows are per-query; these are per-minute aggregates whose whole purpose is
/// to outlive them.
pub const retention_window_s: i64 = 31 * 86_400;
/// One minute of one upstream's outcomes, as the accumulator hands it over.
/// Every string is borrowed for the duration of the call: `Stmt.bindText` binds
/// with `SQLITE_TRANSIENT`, so SQLite copies before `flush` returns.
pub const FlushRow = struct {
url: []const u8,
minute_ts: i64,
successes: u32,
failures: u32,
last_failure_ts: ?i64,
/// Empty when the minute held no failure.
last_error: []const u8,
};
const insert_target_sql = "INSERT OR IGNORE INTO upstream_targets (url) VALUES (?1)";
const select_target_sql = "SELECT id FROM upstream_targets WHERE url = ?1";
/// Additive, and the timestamp columns are max-wins, which is what makes a
/// flush safe to repeat against a row another process already wrote.
///
/// `max()` over a NULL is NULL in SQLite, so the coalesce is what keeps an
/// existing `last_failure_ts` when the incoming row carries none. The `CASE`
/// moves `last_error` with the timestamp it belongs to: a success-only upsert
/// leaves the stored failure and its name exactly as they were.
const upsert_minute_sql =
\\INSERT INTO upstream_minute (upstream_id, minute_ts, successes, failures, last_failure_ts, last_error)
\\VALUES (?1, ?2, ?3, ?4, ?5, ?6)
\\ON CONFLICT(upstream_id, minute_ts) DO UPDATE SET
\\ successes = successes + excluded.successes,
\\ failures = failures + excluded.failures,
\\ last_failure_ts = coalesce(max(last_failure_ts, excluded.last_failure_ts), last_failure_ts, excluded.last_failure_ts),
\\ last_error = CASE
\\ WHEN excluded.last_failure_ts IS NOT NULL
\\ AND (last_failure_ts IS NULL OR excluded.last_failure_ts >= last_failure_ts)
\\ THEN excluded.last_error
\\ ELSE last_error
\\ END
;
/// One transaction for the whole batch: either every minute of the pass lands
/// or none of it does, so a failed flush leaves nothing half-written for the
/// caller's merge-back to double-count.
pub fn flush(database: *db.Db, rows: []const FlushRow) db.Error!void {
if (rows.len == 0) return;
var insert_target = try database.prepare(insert_target_sql);
defer insert_target.deinit();
var select_target = try database.prepare(select_target_sql);
defer select_target.deinit();
var upsert = try database.prepare(upsert_minute_sql);
defer upsert.deinit();
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
for (rows) |row| {
const upstream_id = try internTarget(&insert_target, &select_target, row.url);
try upsert.reset();
try upsert.bindInt(1, upstream_id);
try upsert.bindInt(2, row.minute_ts);
try upsert.bindInt(3, row.successes);
try upsert.bindInt(4, row.failures);
if (row.last_failure_ts) |at| try upsert.bindInt(5, at) else try upsert.bindNull(5);
try upsert.bindText(6, row.last_error);
try upsert.exec();
}
try tx.commit();
}
fn internTarget(insert: *db.Stmt, select: *db.Stmt, url: []const u8) db.Error!i64 {
try insert.reset();
try insert.bindText(1, url);
try insert.exec();
try select.reset();
try select.bindText(1, url);
// The insert above either created the row or found it already there, so a
// miss means the table changed under this connection.
if (!try select.step()) return error.NotFound;
const id = select.columnInt(0);
// A statement stopped on a row keeps its cursor open until it is reset;
// the transaction must not carry that to the next row.
try select.reset();
return id;
}
/// What `GET /api/upstream/health` reports for one upstream over one window.
pub const WindowStats = struct {
attempts: u64,
successes: u64,
failures: u64,
last_failure_ts: ?i64,
/// The error name of the row holding the newest `last_failure_ts` in the
/// window; empty when the window holds no failure.
///
/// By value rather than by slice: the caller loops over upstreams and
/// reuses one `WindowStats`, so a borrowed slice would dangle into the
/// storage the next iteration overwrites.
last_failure_error_buf: [health.error_name_capacity]u8,
last_failure_error_len: u8,
pub fn lastFailureError(self: *const WindowStats) []const u8 {
return self.last_failure_error_buf[0..self.last_failure_error_len];
}
};
/// **One statement, deliberately.** Two statements would not share a SQLite
/// snapshot: the flush connection can commit between them, and the read would
/// then pair a `max(last_failure_ts)` taken from one state with an error text
/// taken from another.
///
/// The error lookup is a scalar subquery for the same reason it is not a bare
/// column: `SELECT max(last_failure_ts), last_error` lets SQLite return the
/// `last_error` of an arbitrary row of the group. `ORDER BY ... DESC, minute_ts
/// DESC` makes the choice deterministic when two minutes share a timestamp.
const window_stats_sql =
\\SELECT coalesce(sum(m.successes), 0), coalesce(sum(m.failures), 0), max(m.last_failure_ts),
\\ (SELECT e.last_error FROM upstream_minute e
\\ WHERE e.upstream_id = m.upstream_id AND e.minute_ts >= ?2 AND e.minute_ts < ?3
\\ AND e.last_failure_ts IS NOT NULL
\\ ORDER BY e.last_failure_ts DESC, e.minute_ts DESC LIMIT 1)
\\ FROM upstream_minute m JOIN upstream_targets t ON t.id = m.upstream_id
\\ WHERE t.url = ?1 AND m.minute_ts >= ?2 AND m.minute_ts < ?3
;
/// Aggregates `[since, until)` by `minute_ts`. An unknown url or an empty
/// window is zeros, a null timestamp and the empty error — not an error.
pub fn windowStats(database: *db.Db, url: []const u8, since: i64, until: i64) db.Error!WindowStats {
var stmt = try database.prepare(window_stats_sql);
defer stmt.deinit();
try stmt.bindText(1, url);
try stmt.bindInt(2, since);
try stmt.bindInt(3, until);
// A bare aggregate always produces exactly one row; no row means the
// statement is not the one this function prepared.
if (!try stmt.step()) return error.Misuse;
const successes = try countOf(stmt.columnInt(0));
const failures = try countOf(stmt.columnInt(1));
var out: WindowStats = .{
.attempts = successes + failures,
.successes = successes,
.failures = failures,
.last_failure_ts = if (stmt.isNull(2)) null else stmt.columnInt(2),
.last_failure_error_buf = @splat(0),
.last_failure_error_len = 0,
};
const name = stmt.columnText(3);
const copied = @min(name.len, out.last_failure_error_buf.len);
@memcpy(out.last_failure_error_buf[0..copied], name[0..copied]);
out.last_failure_error_len = @intCast(copied);
return out;
}
/// `sum` over `CHECK (… >= 0)` columns cannot go negative; a negative value
/// means the row came from something other than this schema.
fn countOf(value: i64) db.Error!u64 {
if (value < 0) return error.Mismatch;
return @intCast(value);
}
/// Deletes every `upstream_minute` row strictly older than `cutoff_ts`, then
/// the targets no surviving row references, and returns how many **minute**
/// rows went.
///
/// One transaction: a target dropped without its rows, or rows dropped while
/// the target delete failed, would leave the foreign key pointing at nothing.
/// The count is minute rows only, so the metric an operator watches counts
/// aggregates rather than dimension-table housekeeping.
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!i64 {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
var minutes = try database.prepare("DELETE FROM upstream_minute WHERE minute_ts < ?1");
defer minutes.deinit();
try minutes.bindInt(1, cutoff_ts);
try minutes.exec();
const deleted = database.changes();
try database.exec(
\\DELETE FROM upstream_targets
\\ WHERE id NOT IN (SELECT upstream_id FROM upstream_minute);
);
try tx.commit();
return deleted;
}
pub fn countMinutes(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM upstream_minute");
}
pub fn countTargets(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM upstream_targets");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const querylog_schema = @import("../querylog_schema.zig");
const testing = std.testing;
fn openLog() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
try database.exec(querylog_schema.ddl);
return database;
}
/// `columnText` is borrowed until the statement is finalized, so the stored
/// error name is copied out rather than returned as a slice.
const StoredMinute = struct {
successes: u32,
failures: u32,
last_failure_ts: ?i64,
error_buf: [health.error_name_capacity]u8,
error_len: u8,
fn lastError(self: *const StoredMinute) []const u8 {
return self.error_buf[0..self.error_len];
}
};
fn readMinute(database: *db.Db, url: []const u8, minute_ts: i64) !StoredMinute {
var stmt = try database.prepare(
\\SELECT m.successes, m.failures, m.last_failure_ts, m.last_error
\\ FROM upstream_minute m JOIN upstream_targets t ON t.id = m.upstream_id
\\ WHERE t.url = ?1 AND m.minute_ts = ?2
);
defer stmt.deinit();
try stmt.bindText(1, url);
try stmt.bindInt(2, minute_ts);
try testing.expect(try stmt.step());
var out: StoredMinute = .{
.successes = @intCast(stmt.columnInt(0)),
.failures = @intCast(stmt.columnInt(1)),
.last_failure_ts = if (stmt.isNull(2)) null else stmt.columnInt(2),
.error_buf = @splat(0),
.error_len = 0,
};
const name = stmt.columnText(3);
@memcpy(out.error_buf[0..name.len], name);
out.error_len = @intCast(name.len);
return out;
}
test "the upsert adds to the row already there rather than replacing it" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 3, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
});
// The shape a restart inside one minute takes: a second process writes the
// same (url, minute) and the counts continue.
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 2, .failures = 4, .last_failure_ts = 90, .last_error = "ConnectFailed" },
});
const row = try readMinute(&database, "https://a.example", 60);
try testing.expectEqual(@as(u32, 5), row.successes);
try testing.expectEqual(@as(u32, 5), row.failures);
try testing.expectEqual(@as(?i64, 90), row.last_failure_ts);
try testing.expectEqualStrings("ConnectFailed", row.lastError());
// One row and one target, not two of either.
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
try testing.expectEqual(@as(i64, 1), try countTargets(&database));
}
test "a success-only upsert keeps the failure timestamp and its error" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
});
// `max()` over a NULL is NULL in SQLite, so without the coalesce this
// upsert would erase the timestamp it knows nothing about.
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 5, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
const row = try readMinute(&database, "https://a.example", 60);
try testing.expectEqual(@as(u32, 5), row.successes);
try testing.expectEqual(@as(u32, 1), row.failures);
try testing.expectEqual(@as(?i64, 70), row.last_failure_ts);
try testing.expectEqualStrings("Timeout", row.lastError());
}
test "an older failure does not overwrite the newer error already stored" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 90, .last_error = "Timeout" },
});
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = "ConnectFailed" },
});
const row = try readMinute(&database, "https://a.example", 60);
try testing.expectEqual(@as(?i64, 90), row.last_failure_ts);
try testing.expectEqualStrings("Timeout", row.lastError());
}
test "flush interns each url once and writes every minute of the batch" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://a.example", .minute_ts = 120, .successes = 2, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://b.example", .minute_ts = 60, .successes = 3, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
try testing.expectEqual(@as(i64, 3), try countMinutes(&database));
try testing.expectEqual(@as(i64, 2), try countTargets(&database));
// An empty batch opens no transaction: one is already open here, so a
// `BEGIN IMMEDIATE` would fail.
var tx = try db.Tx.begin(&database);
try flush(&database, &.{});
tx.rollback();
}
test "windowStats sums only the window and pairs the newest failure with its own error" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
// Before the window.
.{ .url = "https://a.example", .minute_ts = 0, .successes = 9, .failures = 9, .last_failure_ts = 30, .last_error = "Outside" },
.{ .url = "https://a.example", .minute_ts = 60, .successes = 2, .failures = 1, .last_failure_ts = 100, .last_error = "ConnectFailed" },
.{ .url = "https://a.example", .minute_ts = 120, .successes = 4, .failures = 2, .last_failure_ts = 170, .last_error = "Timeout" },
// A later minute with no failure at all: the error must still come from
// the minute holding the newest `last_failure_ts`, not from this one.
.{ .url = "https://a.example", .minute_ts = 180, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
// At the exclusive end of the window.
.{ .url = "https://a.example", .minute_ts = 240, .successes = 7, .failures = 7, .last_failure_ts = 250, .last_error = "After" },
// A different upstream in the same minutes.
.{ .url = "https://b.example", .minute_ts = 120, .successes = 5, .failures = 5, .last_failure_ts = 175, .last_error = "Other" },
});
const stats = try windowStats(&database, "https://a.example", 60, 240);
try testing.expectEqual(@as(u64, 7), stats.successes);
try testing.expectEqual(@as(u64, 3), stats.failures);
try testing.expectEqual(@as(u64, 10), stats.attempts);
try testing.expectEqual(@as(?i64, 170), stats.last_failure_ts);
try testing.expectEqualStrings("Timeout", stats.lastFailureError());
}
test "two minutes sharing the newest failure timestamp resolve to the later minute" {
// The tiebreak the subquery's ORDER BY owns. `max(last_failure_ts)` alone
// cannot choose between these two rows, so without a deterministic second
// key the answer is whichever row SQLite happened to visit.
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 100, .last_error = "Earlier" },
.{ .url = "https://a.example", .minute_ts = 120, .successes = 0, .failures = 1, .last_failure_ts = 100, .last_error = "Later" },
});
const stats = try windowStats(&database, "https://a.example", 0, 1000);
try testing.expectEqual(@as(?i64, 100), stats.last_failure_ts);
try testing.expectEqualStrings("Later", stats.lastFailureError());
}
test "windowStats over an unknown url or an empty window is zeros and no error" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
});
for ([_][3]i64{ .{ 0, 60, 0 }, .{ 120, 180, 0 }, .{ 60, 60, 0 } }) |window| {
const stats = try windowStats(&database, "https://a.example", window[0], window[1]);
try testing.expectEqual(@as(u64, 0), stats.attempts);
try testing.expectEqual(@as(u64, 0), stats.successes);
try testing.expectEqual(@as(u64, 0), stats.failures);
try testing.expectEqual(@as(?i64, null), stats.last_failure_ts);
try testing.expectEqualStrings("", stats.lastFailureError());
}
const unknown = try windowStats(&database, "https://never.example", 0, 1000);
try testing.expectEqual(@as(u64, 0), unknown.attempts);
try testing.expectEqual(@as(?i64, null), unknown.last_failure_ts);
try testing.expectEqualStrings("", unknown.lastFailureError());
}
test "a window whose only failures are outside it reports no failure at all" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 0, .successes = 0, .failures = 1, .last_failure_ts = 30, .last_error = "Timeout" },
.{ .url = "https://a.example", .minute_ts = 60, .successes = 4, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
const stats = try windowStats(&database, "https://a.example", 60, 120);
try testing.expectEqual(@as(u64, 4), stats.attempts);
try testing.expectEqual(@as(?i64, null), stats.last_failure_ts);
try testing.expectEqualStrings("", stats.lastFailureError());
}
test "pruneOlderThan counts minute rows only and drops the orphaned target" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://old.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://old.example", .minute_ts = 120, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://kept.example", .minute_ts = 120, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://kept.example", .minute_ts = 300, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
// Three minute rows go; the two target deletes must not join the count.
try testing.expectEqual(@as(i64, 3), try pruneOlderThan(&database, 300));
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
// "old.example" has nothing left, "kept.example" still does.
try testing.expectEqual(@as(i64, 1), try countTargets(&database));
// The row exactly at the cutoff stays, and a second pass finds nothing.
try testing.expectEqual(@as(i64, 0), try pruneOlderThan(&database, 300));
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
}
test "a failed prune leaves both tables as they were" {
var database = try openLog();
defer database.close();
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
try database.exec(
\\CREATE TRIGGER refuse_target_delete BEFORE DELETE ON upstream_targets
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
);
// The minute delete succeeds and the target delete does not; one
// transaction means neither survives.
try testing.expectError(error.Constraint, pruneOlderThan(&database, 300));
try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
try testing.expectEqual(@as(i64, 1), try countTargets(&database));
}
test "an error name longer than the buffer is truncated, not overflowed" {
var database = try openLog();
defer database.close();
const long = "A" ** 200;
try flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = long },
});
const stats = try windowStats(&database, "https://a.example", 60, 120);
try testing.expectEqual(@as(usize, health.error_name_capacity), stats.lastFailureError().len);
try testing.expectEqualStrings(long[0..health.error_name_capacity], stats.lastFailureError());
}
+2 -102
View File
@@ -16,7 +16,6 @@ const disk_monitor = @import("disk_monitor.zig");
const events = @import("events.zig");
const model = @import("../config/model.zig");
const queries_repo = @import("repositories/queries_repo.zig");
const upstream_history_repo = @import("repositories/upstream_history_repo.zig");
const log = std.log.scoped(.retention);
@@ -33,10 +32,6 @@ pub const pass_interval_s = 86_400;
pub const Stats = struct {
passes: u64 = 0,
rows_pruned: u64 = 0,
/// Upstream-history minute rows, counted apart from `rows_pruned`: that
/// counter is the query log's, and an operator watching it must not see it
/// move because a different table was tidied.
upstream_rows_pruned: u64 = 0,
checkpoints: u64 = 0,
vacuums: u64 = 0,
/// Vacuums the disk monitor refused. The pass still pruned and
@@ -50,7 +45,6 @@ pub const Stats = struct {
const Counters = struct {
passes: std.atomic.Value(u64) = .init(0),
rows_pruned: std.atomic.Value(u64) = .init(0),
upstream_rows_pruned: std.atomic.Value(u64) = .init(0),
checkpoints: std.atomic.Value(u64) = .init(0),
vacuums: std.atomic.Value(u64) = .init(0),
vacuums_gated: std.atomic.Value(u64) = .init(0),
@@ -74,7 +68,6 @@ pub const Retention = struct {
return .{
.passes = self.counters.passes.load(.monotonic),
.rows_pruned = self.counters.rows_pruned.load(.monotonic),
.upstream_rows_pruned = self.counters.upstream_rows_pruned.load(.monotonic),
.checkpoints = self.counters.checkpoints.load(.monotonic),
.vacuums = self.counters.vacuums.load(.monotonic),
.vacuums_gated = self.counters.vacuums_gated.load(.monotonic),
@@ -124,22 +117,6 @@ pub const Retention = struct {
maintenance(store, io, now, "prune", @errorName(err));
}
// Before the vacuum-cadence logic below, which returns early on six
// passes out of seven and again whenever the monitor refuses the
// vacuum. A step placed after it would almost never run.
//
// `logging.retention_days` is not the window here: these are per-minute
// aggregates whose whole purpose is to outlive the per-query rows, so
// the window is the repository's own constant.
const history_cutoff = now - upstream_history_repo.retention_window_s;
if (upstream_history_repo.pruneOlderThan(database, history_cutoff)) |deleted| {
add(&self.counters.upstream_rows_pruned, @intCast(deleted));
maintenance(store, io, now, "history_prune", null);
} else |err| {
log.warn("upstream history prune before {d} failed: {s}", .{ history_cutoff, @errorName(err) });
maintenance(store, io, now, "history_prune", @errorName(err));
}
if (queries_repo.checkpointTruncate(database)) {
add(&self.counters.checkpoints, 1);
maintenance(store, io, now, "checkpoint", null);
@@ -443,83 +420,6 @@ test "a failing prune counts the pass and leaves the rows alone" {
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().checkpoints);
}
test "the upstream-history window is its own, and a one-day query log does not shrink it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const now = std.Io.Clock.real.now(io).toSeconds();
const day = 86_400;
try writeRows(&database, &.{ now - 2 * day, now - 60 });
try upstream_history_repo.flush(&database, &.{
.{ .url = "https://gone.example", .minute_ts = now - 32 * day, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = "https://kept.example", .minute_ts = now - 29 * day, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
// A query log kept for one day, and 30 days of upstream minutes beside it.
var retention: Retention = .init(.{ .retention_days = 1 });
retention.runOnce(io, &database, null, null);
const stats = retention.snapshotStats();
// One query-log row is older than one day; one minute row is older than the
// fixed 31-day upstream window. Each counter moved by its own amount.
try testing.expectEqual(@as(u64, 1), stats.rows_pruned);
try testing.expectEqual(@as(u64, 1), stats.upstream_rows_pruned);
try testing.expectEqual(@as(i64, 1), try upstream_history_repo.countMinutes(&database));
// The day-29 row is exactly what a 30-day dashboard window asks for.
const kept = try upstream_history_repo.windowStats(
&database,
"https://kept.example",
now - 30 * day,
now,
);
try testing.expectEqual(@as(u64, 1), kept.attempts);
// And the emptied target went with its rows.
try testing.expectEqual(@as(i64, 1), try upstream_history_repo.countTargets(&database));
}
test "the history prune runs on the passes where the vacuum logic returns early" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const now = std.Io.Clock.real.now(io).toSeconds();
const old_minute = now - 40 * 86_400;
// The first pass: `passes_since_vacuum` is 1, so the vacuum block returns
// before it does anything. A prune placed after that block would never run
// on six passes out of seven.
try upstream_history_repo.flush(&database, &.{
.{ .url = "https://a.example", .minute_ts = old_minute, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
var early: Retention = .init(.{});
early.runOnce(io, &database, null, null);
try testing.expectEqual(@as(u64, 1), early.snapshotStats().upstream_rows_pruned);
try testing.expectEqual(@as(i64, 0), try upstream_history_repo.countMinutes(&database));
// The gated pass: the disk monitor refuses the vacuum and that branch
// returns too, and the prune still has to have happened before it.
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
var gated: Retention = .init(.{});
for (0..vacuum_every_passes - 1) |_| gated.runOnce(io, &database, &monitor, null);
try upstream_history_repo.flush(&database, &.{
.{ .url = "https://b.example", .minute_ts = old_minute, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
gated.runOnce(io, &database, &monitor, null);
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().vacuums_gated);
try testing.expectEqual(@as(u64, 1), gated.snapshotStats().upstream_rows_pruned);
try testing.expectEqual(@as(i64, 0), try upstream_history_repo.countMinutes(&database));
}
test "the next pass retries what the failed one could not do" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
@@ -569,8 +469,8 @@ test "a failing prune opens a maintenance episode the next clean pass closes" {
var retention: Retention = .init(.{});
retention.runOnce(io, &database, null, &fx.store);
// Only the prune failed; checkpoint and history prune succeeded, and a
// success writes no row of its own.
// Only the prune failed; the checkpoint succeeded, and a success writes no
// row of its own.
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
try testing.expectEqualStrings("query_log.maintenance", try fx.text("SELECT code FROM operational_events"));
try testing.expectEqualStrings("prune", try fx.text("SELECT subject_key FROM operational_events"));
-3
View File
@@ -77,11 +77,9 @@ comptime {
_ = @import("cache/dns_cache.zig");
_ = @import("server/rate_limiter.zig");
_ = @import("storage/repositories/queries_repo.zig");
_ = @import("storage/repositories/upstream_history_repo.zig");
_ = @import("storage/repositories/events_repo.zig");
_ = @import("storage/events.zig");
_ = @import("storage/events_fixture.zig");
_ = @import("upstream/history.zig");
_ = @import("storage/logger.zig");
_ = @import("platform/statfs.zig");
_ = @import("storage/disk_monitor.zig");
@@ -109,7 +107,6 @@ comptime {
_ = @import("web/handlers/queries.zig");
_ = @import("web/handlers/diagnostics.zig");
_ = @import("web/handlers/lookup.zig");
_ = @import("web/handlers/upstream_health.zig");
_ = @import("web/handlers/health.zig");
_ = @import("web/handlers/version.zig");
_ = @import("web/handlers/mutations.zig");
+1 -4
View File
@@ -45,10 +45,7 @@ pub const Config = struct {
/// `State.window`.
pub const window_len = 32;
/// Bytes kept of an `@errorName`, truncated to fit. Shared rather than repeated:
/// `history.Accumulator.Cell` and `upstream_history_repo.WindowStats` carry the
/// same name through the minute aggregates, and three buffers of three different
/// sizes would truncate one error name three ways.
/// Bytes kept of an `@errorName`, truncated to fit.
pub const error_name_capacity = 48;
/// The shift is capped so `base_backoff_ms << shift` cannot run away; by then
-716
View File
@@ -1,716 +0,0 @@
//! Per-minute upstream outcome history (milestone-26 rulings 1, 3, 4).
//!
//! Outcomes are aggregated into their wall-clock UTC minute at the moment the
//! pool records them, and the aggregates are flushed to `querylog.db` once a
//! minute. **Nothing samples a lifetime counter and subtracts.** That is what
//! makes the stored numbers additive facts: a restart inside a minute adds to
//! the same row, a crash loses at most the cells that had not flushed yet, and
//! no path anywhere can produce a negative delta.
//!
//! The query path may not touch SQLite, so recording is memory-only under this
//! module's own mutex and the writing happens on a task of its own.
//!
//! Two failure modes, deliberately kept apart:
//!
//! * **Overflow.** More live `(url, minute)` pairs than `max_pending`. The
//! oldest minute is dropped, `rows_dropped` counts it and
//! `last_drop_minute` remembers how new the newest lost minute was, so a
//! window that starts after it can still be reported as complete.
//! * **A failed flush.** Nothing is dropped: the rows go back into the
//! accumulator and the next pass writes them again.
//!
//! The wall clock, not `.awake`: history participates in wall-clock periods, so
//! a minute here is the same minute the dashboard's period picker means.
//! Routing state (`health.zig`) stays on `.awake` and is untouched by this
//! file.
const std = @import("std");
const db = @import("../storage/db.zig");
const events = @import("../storage/events.zig");
const health = @import("health.zig");
const upstream_history_repo = @import("../storage/repositories/upstream_history_repo.zig");
const log = std.log.scoped(.upstream_history);
/// Live `(url, minute)` cells. Not a knob (m26 anti-requirements). A household
/// pool is a handful of upstreams, so the bound is only reachable when flushing
/// has been failing for hours.
pub const max_pending = 4096;
/// One flush pass per minute, so an unflushed cell is at most about a minute
/// old. `.boot` rather than `.awake`, for `retention.zig`'s reason: a box that
/// suspends must still see its interval elapse.
pub const flush_interval_s = 60;
/// The UTC minute `wall_s` falls in, as its start in seconds. `@divFloor`, not
/// `@divTrunc`: a negative second belongs to the minute before it.
pub fn minuteOf(wall_s: i64) i64 {
return @divFloor(wall_s, 60) * 60;
}
/// The write seam. Production passes `upstream_history_repo.flush`; a test
/// passes a stub that fails, or one that records what it was handed.
pub const WriteFn = *const fn (*db.Db, []const upstream_history_repo.FlushRow) db.Error!void;
pub const Accumulator = struct {
pub const Cell = struct {
/// Borrowed from the pool entry's endpoint, which lives as long as the
/// process. Nothing here copies it, and nothing here may outlive it.
url: []const u8,
minute_ts: i64,
/// Both counters saturate instead of wrapping: every write site uses
/// `+|=` — `recordSuccess`, `recordFailure`, and `mergeBack`, which
/// sums a failed flush's copy back into the live cell. The saturation
/// is deliberate and unreachable: a cell counts one upstream's
/// outcomes inside a single wall-clock minute, so filling a `u32`
/// would take about 72 million exchanges per second with that one
/// upstream. Nothing reports it, by design — `last_drop_minute` and
/// the `complete` flag it feeds describe capacity drops, and a
/// saturated counter is not a drop.
successes: u32,
failures: u32,
last_failure_ts: ?i64,
last_error_buf: [health.error_name_capacity]u8,
last_error_len: u8,
fn lastError(self: *const Cell) []const u8 {
return self.last_error_buf[0..self.last_error_len];
}
};
/// A consistent copy for `/metrics`, `/api/health` and the API layer.
pub const Stats = struct {
flushes: u64 = 0,
flush_failures: u64 = 0,
rows_dropped: u64 = 0,
pending: u32 = 0,
/// The newest minute capacity has ever cost this process, or null when
/// nothing was ever dropped. A window that starts after it is complete
/// again, so one historical overflow does not mark every later answer.
last_drop_minute: ?i64 = null,
/// Current state, not a count: set by a failed flush and cleared by the
/// next successful one. Feeds the `/api/health` rollup.
last_flush_failed: bool = false,
};
/// Atomic for the reason `retention.zig`'s are: the flush task writes them
/// and the web task reads them, on different threads. They are bumped
/// outside the mutex, so the lock is not what orders them.
const Counters = struct {
flushes: std.atomic.Value(u64) = .init(0),
flush_failures: std.atomic.Value(u64) = .init(0),
rows_dropped: std.atomic.Value(u64) = .init(0),
};
mutex: std.Io.Mutex = .init,
cells: [max_pending]Cell,
count: u32,
last_drop_minute: ?i64,
last_flush_failed: bool,
counters: Counters,
/// Owned by whichever task runs `flushOnce`, which is one task. It is a
/// field rather than a local so that the megabyte it costs lives wherever
/// the accumulator was placed instead of on a task's stack.
flush_cells: [max_pending]Cell,
flush_rows: [max_pending]upstream_history_repo.FlushRow,
flush_count: u32,
/// Set once by the composition root after `init`, following the
/// `gate: ?*disk_monitor.Monitor` idiom. Null everywhere else, and every
/// emit site below is inert when it is.
diagnostics: ?*events.Store = null,
/// `cells` and `flush_cells` are `undefined`: a cell is always written
/// before it is read, and `count` is what says which ones exist.
pub const init: Accumulator = .{
.mutex = .init,
.cells = undefined,
.count = 0,
.last_drop_minute = null,
.last_flush_failed = false,
.counters = .{},
.flush_cells = undefined,
.flush_rows = undefined,
.flush_count = 0,
};
pub fn recordSuccess(self: *Accumulator, io: std.Io, url: []const u8, wall_s: i64) void {
// Uncancelable for the reason the pool's health sections are: this
// takes no Io and never blocks on a peer, and losing the record of a
// completed exchange to a cancellation would undercount for good.
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const cell = self.cellFor(url, minuteOf(wall_s));
cell.successes +|= 1;
}
pub fn recordFailure(
self: *Accumulator,
io: std.Io,
url: []const u8,
wall_s: i64,
error_name: []const u8,
) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const cell = self.cellFor(url, minuteOf(wall_s));
cell.failures +|= 1;
noteFailure(cell, wall_s, error_name);
}
/// The only read surface. Every field above is private to this module, so
/// no consumer can read one of them without the mutex.
pub fn snapshotStats(self: *Accumulator, io: std.Io) Stats {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return .{
.flushes = self.counters.flushes.load(.monotonic),
.flush_failures = self.counters.flush_failures.load(.monotonic),
.rows_dropped = self.counters.rows_dropped.load(.monotonic),
.pending = self.count,
.last_drop_minute = self.last_drop_minute,
.last_flush_failed = self.last_flush_failed,
};
}
/// The cell for `(url, minute_ts)`, created if it is not there yet. The
/// caller holds the mutex.
///
/// Linear: the live set is one cell per upstream per unflushed minute,
/// which at household scale is a handful. A miss at capacity evicts the
/// oldest minute — see `evictOldest`.
fn cellFor(self: *Accumulator, url: []const u8, minute_ts: i64) *Cell {
for (self.cells[0..self.count]) |*cell| {
if (cell.minute_ts != minute_ts) continue;
// Pointer equality first: every call from the pool passes the same
// `Entry.endpoint.url`, so the byte compare is the cold path.
if (cell.url.ptr == url.ptr and cell.url.len == url.len) return cell;
if (std.mem.eql(u8, cell.url, url)) return cell;
}
const slot = if (self.count < max_pending) fresh: {
const index = self.count;
self.count += 1;
break :fresh &self.cells[index];
} else self.evictOldest();
slot.* = .{
.url = url,
.minute_ts = minute_ts,
.successes = 0,
.failures = 0,
.last_failure_ts = null,
.last_error_buf = @splat(0),
.last_error_len = 0,
};
return slot;
}
/// Frees the cell holding the oldest minute and accounts for what it cost.
///
/// `last_drop_minute` moves through `@max` and never through assignment: a
/// merge-back after a failed flush can evict a cell older than one already
/// dropped, and a watermark that moved backwards would report a window as
/// complete when outcomes inside it are gone.
fn evictOldest(self: *Accumulator) *Cell {
var oldest: usize = 0;
for (self.cells[1..self.count], 1..) |*cell, i| {
if (cell.minute_ts < self.cells[oldest].minute_ts) oldest = i;
}
const evicted = self.cells[oldest].minute_ts;
self.last_drop_minute = @max(self.last_drop_minute orelse evicted, evicted);
_ = self.counters.rows_dropped.fetchAdd(1, .monotonic);
return &self.cells[oldest];
}
/// One flush pass: swap the dirty cells out, write them, and on failure put
/// them back.
///
/// **A swap, never a subtraction.** SQLite runs outside the mutex, so while
/// it does, a full accumulator can evict a cell that was copied out and
/// then recreate the same `(url, minute_ts)`. A post-flush subtract would
/// then destroy outcomes recorded during the write. Moving the cells out
/// makes the flush own them: what is recorded beside it is new data, and a
/// failure merges the two additively.
///
/// Every failure is counted and warned about once; nothing here returns an
/// error, because there is no caller that could do anything the next pass
/// will not do anyway.
pub fn flushOnce(self: *Accumulator, io: std.Io, database: *db.Db, write: WriteFn) void {
{
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
if (self.count == 0) return;
@memcpy(self.flush_cells[0..self.count], self.cells[0..self.count]);
self.flush_count = self.count;
self.count = 0;
}
const rows = self.flush_rows[0..self.flush_count];
for (self.flush_cells[0..self.flush_count], rows) |*cell, *row| {
row.* = .{
.url = cell.url,
.minute_ts = cell.minute_ts,
.successes = cell.successes,
.failures = cell.failures,
.last_failure_ts = cell.last_failure_ts,
.last_error = cell.lastError(),
};
}
if (write(database, rows)) {
_ = self.counters.flushes.fetchAdd(1, .monotonic);
if (self.diagnostics) |store| {
store.resolve(io, std.Io.Clock.real.now(io).toSeconds(), .upstream_history_write, flush_key);
}
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.last_flush_failed = false;
return;
} else |err| {
_ = self.counters.flush_failures.fetchAdd(1, .monotonic);
log.warn("flushing {d} upstream history rows failed: {s}", .{ rows.len, @errorName(err) });
if (self.diagnostics) |store| {
var buf: [events.Store.max_detail_len]u8 = undefined;
const detail = std.fmt.bufPrint(&buf, "flushing {d} upstream history rows failed: {s}", .{
rows.len,
@errorName(err),
}) catch buf[0..];
store.report(
io,
std.Io.Clock.real.now(io).toSeconds(),
.upstream_history_write,
flush_key,
"upstream history flush",
.warning,
detail,
);
}
self.mergeBack(io);
}
}
/// Puts a failed pass's cells back through the rules recording uses: a cell
/// recorded during the write keeps its outcomes and the merge sums into it,
/// and a merge that overflows follows the ordinary drop policy.
fn mergeBack(self: *Accumulator, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.last_flush_failed = true;
for (self.flush_cells[0..self.flush_count]) |*saved| {
const cell = self.cellFor(saved.url, saved.minute_ts);
cell.successes +|= saved.successes;
cell.failures +|= saved.failures;
if (saved.last_failure_ts) |at| noteFailure(cell, at, saved.lastError());
}
}
/// Daily-loop shape (`retention.zig`): flush first, then sleep, so a
/// process that is about to be canceled has already written once.
pub fn run(self: *Accumulator, io: std.Io, database: *db.Db) std.Io.Cancelable!void {
const interval: std.Io.Clock.Duration = .{
.raw = .fromSeconds(flush_interval_s),
.clock = .boot,
};
while (true) {
self.flushOnce(io, database, upstream_history_repo.flush);
try interval.sleep(io);
}
}
};
/// One accumulator, one flush task, one table: the subject of every
/// `upstream_history.write` episode is that single writer.
const flush_key = "flush";
/// Max-wins, matching the SQL upsert exactly: the newest failure in the minute
/// is the one whose name the cell keeps.
fn noteFailure(cell: *Accumulator.Cell, at: i64, error_name: []const u8) void {
if (cell.last_failure_ts) |existing| {
if (at < existing) return;
}
cell.last_failure_ts = at;
const copied = @min(error_name.len, cell.last_error_buf.len);
@memcpy(cell.last_error_buf[0..copied], error_name[0..copied]);
cell.last_error_len = @intCast(copied);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const events_fixture = @import("../storage/events_fixture.zig");
const querylog_schema = @import("../storage/querylog_schema.zig");
const testing = std.testing;
fn openLog() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
try database.exec(querylog_schema.ddl);
return database;
}
/// The accumulator is about a megabyte, which is more than a test frame should
/// carry.
fn newAccumulator() !*Accumulator {
const acc = try testing.allocator.create(Accumulator);
acc.* = .init;
return acc;
}
fn failingWrite(_: *db.Db, _: []const upstream_history_repo.FlushRow) db.Error!void {
return error.Busy;
}
/// What the last stubbed flush was handed, copied out so an assertion can read
/// it after the pass returned.
var recorded: [8]upstream_history_repo.FlushRow = undefined;
var recorded_len: usize = 0;
fn recordingWrite(_: *db.Db, rows: []const upstream_history_repo.FlushRow) db.Error!void {
recorded_len = @min(rows.len, recorded.len);
@memcpy(recorded[0..recorded_len], rows[0..recorded_len]);
}
/// The interleaving of ruling 4: a flush is in flight, and the recording side
/// recreates a swapped-out cell and then fills the accumulator to overflow.
var interleaved: ?*Accumulator = null;
var interleave_io: ?std.Io = null;
var interleave_urls: [max_pending][8]u8 = undefined;
fn interleavingWrite(_: *db.Db, _: []const upstream_history_repo.FlushRow) db.Error!void {
const acc = interleaved.?;
const io = interleave_io.?;
// The very `(url, minute_ts)` the flush is holding, recorded again while
// the write runs.
acc.recordSuccess(io, "https://a.example", 60);
// And then enough distinct minutes to fill the accumulator and evict.
for (&interleave_urls, 0..) |*name, i| {
const url = std.fmt.bufPrint(name, "u{d:0>6}", .{i}) catch unreachable;
acc.recordSuccess(io, url, @as(i64, @intCast(i)) * 600 + 6000);
}
return error.Busy;
}
test "minuteOf floors to the minute, including before the epoch" {
try testing.expectEqual(@as(i64, 0), minuteOf(0));
try testing.expectEqual(@as(i64, 0), minuteOf(59));
try testing.expectEqual(@as(i64, 60), minuteOf(60));
try testing.expectEqual(@as(i64, 120), minuteOf(179));
try testing.expectEqual(@as(i64, -60), minuteOf(-1));
}
test "outcomes land in the cell of their own minute and upstream" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
acc.recordSuccess(io, "https://a.example", 65);
acc.recordSuccess(io, "https://a.example", 119);
acc.recordFailure(io, "https://a.example", 100, "Timeout");
acc.recordSuccess(io, "https://a.example", 130);
acc.recordSuccess(io, "https://b.example", 70);
// Three cells: a/60, a/120 and b/60.
try testing.expectEqual(@as(u32, 3), acc.snapshotStats(io).pending);
const first = acc.cellFor("https://a.example", 60);
try testing.expectEqual(@as(u32, 2), first.successes);
try testing.expectEqual(@as(u32, 1), first.failures);
try testing.expectEqual(@as(?i64, 100), first.last_failure_ts);
try testing.expectEqualStrings("Timeout", first.lastError());
const second = acc.cellFor("https://a.example", 120);
try testing.expectEqual(@as(u32, 1), second.successes);
try testing.expectEqual(@as(u32, 0), second.failures);
try testing.expectEqual(@as(?i64, null), second.last_failure_ts);
}
test "a cell keeps the newest failure's error and ignores an older one" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
acc.recordFailure(io, "https://a.example", 100, "Timeout");
acc.recordFailure(io, "https://a.example", 80, "ConnectFailed");
const cell = acc.cellFor("https://a.example", 60);
try testing.expectEqual(@as(u32, 2), cell.failures);
try testing.expectEqual(@as(?i64, 100), cell.last_failure_ts);
try testing.expectEqualStrings("Timeout", cell.lastError());
acc.recordFailure(io, "https://a.example", 110, "BadResponse");
try testing.expectEqual(@as(?i64, 110), cell.last_failure_ts);
try testing.expectEqualStrings("BadResponse", cell.lastError());
const long = "A" ** 200;
acc.recordFailure(io, "https://a.example", 115, long);
try testing.expectEqual(@as(usize, health.error_name_capacity), cell.lastError().len);
}
test "at capacity the oldest minute is dropped, counted, and the watermark only moves forward" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
var names: [max_pending][8]u8 = undefined;
for (&names, 0..) |*name, i| {
const url = std.fmt.bufPrint(name, "u{d:0>6}", .{i}) catch unreachable;
// Minute 600 is the oldest; every later cell is newer.
acc.recordSuccess(io, url, 600 + @as(i64, @intCast(i)) * 60);
}
try testing.expectEqual(@as(u32, max_pending), acc.snapshotStats(io).pending);
try testing.expectEqual(@as(u64, 0), acc.snapshotStats(io).rows_dropped);
try testing.expectEqual(@as(?i64, null), acc.snapshotStats(io).last_drop_minute);
// One more cell evicts the oldest minute and nothing else.
acc.recordSuccess(io, "https://new.example", 10_000_000);
const after = acc.snapshotStats(io);
try testing.expectEqual(@as(u32, max_pending), after.pending);
try testing.expectEqual(@as(u64, 1), after.rows_dropped);
try testing.expectEqual(@as(?i64, 600), after.last_drop_minute);
// A later eviction of an *older* minute must not move the watermark back.
acc.recordSuccess(io, "https://older.example", 120);
const back = acc.snapshotStats(io);
try testing.expectEqual(@as(u64, 2), back.rows_dropped);
try testing.expectEqual(@as(?i64, 660), back.last_drop_minute);
acc.recordSuccess(io, "https://newer.example", 20_000_000);
const forward = acc.snapshotStats(io);
try testing.expectEqual(@as(u64, 3), forward.rows_dropped);
// The minute just evicted is 120, older than the 660 already recorded.
try testing.expectEqual(@as(?i64, 660), forward.last_drop_minute);
}
test "a successful flush hands over every cell, empties the accumulator and counts once" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
acc.recordSuccess(io, "https://a.example", 65);
acc.recordFailure(io, "https://a.example", 100, "Timeout");
acc.flushOnce(io, &database, recordingWrite);
try testing.expectEqual(@as(usize, 1), recorded_len);
try testing.expectEqualStrings("https://a.example", recorded[0].url);
try testing.expectEqual(@as(i64, 60), recorded[0].minute_ts);
try testing.expectEqual(@as(u32, 1), recorded[0].successes);
try testing.expectEqual(@as(u32, 1), recorded[0].failures);
try testing.expectEqual(@as(?i64, 100), recorded[0].last_failure_ts);
try testing.expectEqualStrings("Timeout", recorded[0].last_error);
const stats = acc.snapshotStats(io);
try testing.expectEqual(@as(u32, 0), stats.pending);
try testing.expectEqual(@as(u64, 1), stats.flushes);
try testing.expectEqual(@as(u64, 0), stats.flush_failures);
try testing.expect(!stats.last_flush_failed);
}
test "a pass with nothing pending counts neither a flush nor a failure" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
acc.flushOnce(io, &database, failingWrite);
acc.flushOnce(io, &database, recordingWrite);
const stats = acc.snapshotStats(io);
try testing.expectEqual(@as(u64, 0), stats.flushes);
try testing.expectEqual(@as(u64, 0), stats.flush_failures);
try testing.expect(!stats.last_flush_failed);
}
test "a failed flush keeps the rows, sets the flag, and the next success clears it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
acc.recordSuccess(io, "https://a.example", 65);
acc.recordFailure(io, "https://a.example", 100, "Timeout");
acc.flushOnce(io, &database, failingWrite);
const failed = acc.snapshotStats(io);
try testing.expectEqual(@as(u64, 0), failed.flushes);
try testing.expectEqual(@as(u64, 1), failed.flush_failures);
try testing.expectEqual(@as(u64, 0), failed.rows_dropped);
try testing.expect(failed.last_flush_failed);
// Nothing was lost: the cell is back, whole.
try testing.expectEqual(@as(u32, 1), failed.pending);
const cell = acc.cellFor("https://a.example", 60);
try testing.expectEqual(@as(u32, 1), cell.successes);
try testing.expectEqual(@as(u32, 1), cell.failures);
try testing.expectEqualStrings("Timeout", cell.lastError());
// The retry writes what the failed pass could not.
acc.recordSuccess(io, "https://a.example", 70);
acc.flushOnce(io, &database, recordingWrite);
const cleared = acc.snapshotStats(io);
try testing.expectEqual(@as(u64, 1), cleared.flushes);
try testing.expectEqual(@as(u64, 1), cleared.flush_failures);
try testing.expect(!cleared.last_flush_failed);
try testing.expectEqual(@as(usize, 1), recorded_len);
try testing.expectEqual(@as(u32, 2), recorded[0].successes);
try testing.expectEqual(@as(u32, 1), recorded[0].failures);
}
test "a merge-back sums into what was recorded beside the flush" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
// The write recreates the swapped-out cell before it fails, which is the
// interleaving the swap makes possible.
interleaved = acc;
interleave_io = io;
defer {
interleaved = null;
interleave_io = null;
}
acc.recordSuccess(io, "https://a.example", 65);
acc.recordFailure(io, "https://a.example", 100, "Timeout");
acc.flushOnce(io, &database, interleavingWrite);
const stats = acc.snapshotStats(io);
try testing.expect(stats.last_flush_failed);
try testing.expectEqual(@as(u64, 1), stats.flush_failures);
// Two drops, and they are different drops: filling to capacity during the
// write evicted the recreated minute 60, and the merge-back then found no
// room either and evicted the oldest of what the write had left, 6000.
// Overflow loss is the specced policy; what matters is that it is counted.
try testing.expectEqual(@as(u32, max_pending), stats.pending);
try testing.expectEqual(@as(u64, 2), stats.rows_dropped);
// Forward only: the second eviction was the newer minute of the two.
try testing.expectEqual(@as(?i64, 6000), stats.last_drop_minute);
// The merged cell is back, carrying what the failed flush was holding.
const merged = acc.cellFor("https://a.example", 60);
try testing.expectEqual(@as(u32, 1), merged.successes);
try testing.expectEqual(@as(u32, 1), merged.failures);
try testing.expectEqualStrings("Timeout", merged.lastError());
// The flush-owned buffer was not touched by any of the recording that
// happened beside it: it still holds exactly what was swapped out.
try testing.expectEqual(@as(u32, 1), acc.flush_count);
try testing.expectEqual(@as(u32, 1), acc.flush_cells[0].successes);
try testing.expectEqual(@as(u32, 1), acc.flush_cells[0].failures);
try testing.expectEqual(@as(i64, 60), acc.flush_cells[0].minute_ts);
}
test "a flush against the real repository writes the minute rows" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
acc.recordSuccess(io, "https://a.example", 65);
acc.recordFailure(io, "https://a.example", 100, "Timeout");
acc.recordSuccess(io, "https://b.example", 130);
acc.flushOnce(io, &database, upstream_history_repo.flush);
// The second pass is the restart shape: the same minute, added to.
acc.recordSuccess(io, "https://a.example", 90);
acc.flushOnce(io, &database, upstream_history_repo.flush);
const stats = try upstream_history_repo.windowStats(&database, "https://a.example", 0, 200);
try testing.expectEqual(@as(u64, 2), stats.successes);
try testing.expectEqual(@as(u64, 1), stats.failures);
try testing.expectEqual(@as(u64, 3), stats.attempts);
try testing.expectEqual(@as(?i64, 100), stats.last_failure_ts);
try testing.expectEqualStrings("Timeout", stats.lastFailureError());
try testing.expectEqual(@as(i64, 2), try upstream_history_repo.countMinutes(&database));
try testing.expectEqual(@as(u64, 2), acc.snapshotStats(io).flushes);
}
test "a failed flush opens one episode and the next successful flush closes it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
var fx: events_fixture.Fixture = .{};
try fx.init(io, 1000);
defer fx.deinit();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
acc.diagnostics = &fx.store;
acc.recordFailure(io, "https://a.example", 1_700_000_000, "Timeout");
acc.flushOnce(io, &database, failingWrite);
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
try testing.expectEqualStrings("upstream_history.write", try fx.text("SELECT code FROM operational_events"));
try testing.expectEqualStrings("flush", try fx.text("SELECT subject_key FROM operational_events"));
try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events"));
try testing.expectEqual(
@as(i64, 1),
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
);
acc.flushOnce(io, &database, upstream_history_repo.flush);
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
try testing.expectEqual(
@as(i64, 0),
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
);
}
test "a flush with no store attached records nothing and still flushes" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const acc = try newAccumulator();
defer testing.allocator.destroy(acc);
acc.recordFailure(io, "https://a.example", 1_700_000_000, "Timeout");
acc.flushOnce(io, &database, failingWrite);
acc.flushOnce(io, &database, upstream_history_repo.flush);
try testing.expectEqual(@as(u64, 1), acc.counters.flushes.load(.monotonic));
}
+15 -98
View File
@@ -49,7 +49,6 @@ const std = @import("std");
const events = @import("../storage/events.zig");
const health = @import("health.zig");
const history_mod = @import("history.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig");
@@ -90,14 +89,13 @@ pub const Entry = struct {
busy: std.Io.Mutex = .init,
};
/// A copy of one entry's health, taken under the mutex. Feeds
/// `GET /api/upstream/health`.
/// A copy of one entry's health, taken under the mutex. Feeds `/metrics` and
/// the `/api/health` upstream condition.
pub const Snapshot = struct {
/// Whole, not redacted. `GET /api/upstream/health` returns this to a session
/// that `GET /api/upstreams` already serves the same url to in full, so
/// redacting here would hide nothing from that reader and would make two
/// responses of one API disagree. A consumer reachable without a session has
/// to redact it itself.
/// Whole, not redacted. `GET /api/upstreams` already serves the same url in
/// full to a session, so redacting here would hide nothing from that reader
/// and would make two responses of one API disagree. A consumer reachable
/// without a session has to redact it itself.
url: []const u8,
enabled: bool,
available: bool,
@@ -131,14 +129,11 @@ pub const Pool = struct {
timeouts: Timeouts,
mutex: std.Io.Mutex,
rng: std.Random.DefaultPrng,
/// Where recorded outcomes also go, as per-minute aggregates for the
/// dashboard's ranged view (m26). Defaulted rather than an `init`
/// parameter: the composition root wires it after the pool exists, and the
/// pool is fully usable without it — `nxdns check` and every unit test here
/// run with no history at all.
history: ?*history_mod.Accumulator = null,
/// The diagnostics store, wired the same way and for the same reason as
/// `history`. Every emit here sits outside `mutex`; see `recordHistory`.
/// The diagnostics store. Defaulted rather than an `init` parameter: the
/// composition root wires it after the pool exists, and the pool is fully
/// usable without it — `nxdns check` and every unit test here run with no
/// store at all. Every emit here sits outside `mutex`; see
/// `recordDiagnostics`.
diagnostics: ?*events.Store = null,
pub fn init(
@@ -345,9 +340,8 @@ pub const Pool = struct {
entry.health.recordSuccess(at);
}
// The block above closes before this line, and that ordering is the
// constraint: the accumulator takes a mutex of its own, and no task may
// hold one of the two while it takes the other.
self.recordHistory(io, entry, .success);
// constraint: the store takes a mutex of its own, and no task may hold
// one of the two while it takes the other.
self.recordDiagnostics(io, entry, .success);
}
@@ -365,26 +359,13 @@ pub const Pool = struct {
}
// After the pool mutex is released, for the reason `recordSuccess`
// states.
self.recordHistory(io, entry, .{ .failure = @errorName(err) });
self.recordDiagnostics(io, entry, .{ .failure = @errorName(err) });
}
const Outcome = union(enum) { success, failure: []const u8 };
/// The wall clock, not the `.awake` timestamp the health state runs on:
/// history is aggregated into wall-clock minutes so a dashboard period
/// means the same thing here as everywhere else on the page.
fn recordHistory(self: *Pool, io: std.Io, entry: *Entry, outcome: Outcome) void {
const history = self.history orelse return;
const wall_s = std.Io.Clock.real.now(io).toSeconds();
switch (outcome) {
.success => history.recordSuccess(io, entry.endpoint.url, wall_s),
.failure => |name| history.recordFailure(io, entry.endpoint.url, wall_s, name),
}
}
/// The same placement discipline as `recordHistory`: the store takes a
/// mutex of its own, so this runs after the pool's is released.
/// The store takes a mutex of its own, so this runs after the pool's is
/// released.
///
/// A success is the steady state of the whole program, so `resolve` is
/// built to issue no SQL when nothing is open (`storage/events.zig`).
@@ -408,10 +389,7 @@ pub const Pool = struct {
}
};
const db = @import("../storage/db.zig");
const events_fixture = @import("../storage/events_fixture.zig");
const querylog_schema = @import("../storage/querylog_schema.zig");
const upstream_history_repo = @import("../storage/repositories/upstream_history_repo.zig");
const testing = std.testing;
@@ -921,67 +899,6 @@ test "every entry disabled yields ConnectFailed without waiting out the total bu
try testing.expectEqual(@as(usize, 0), two.calls);
}
test "a wired accumulator receives both outcomes the pool records" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var bad: Fake = .{ .behavior = .{ .fail = error.Timeout } };
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://bad.example/dns-query", &bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
const acc = try testing.allocator.create(history_mod.Accumulator);
defer testing.allocator.destroy(acc);
acc.* = .init;
pool.history = acc;
var buf: [512]u8 = undefined;
// One exchange: the first entry fails over into the second, so this drives
// one failure and one success.
var selected: ?[]const u8 = null;
_ = try pool.exchange(io, query_bytes, &buf, &selected);
// Two cells, one per url, in whatever minute the wall clock is in.
try testing.expectEqual(@as(u32, 2), acc.snapshotStats(io).pending);
// Read back through the flush path rather than through the accumulator's
// private cells: the whole point of the hook is that these outcomes reach
// storage under the right url.
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
try database.exec(querylog_schema.ddl);
acc.flushOnce(io, &database, upstream_history_repo.flush);
// A window wide enough that a minute boundary crossed mid-test changes
// nothing about what it contains.
const now = std.Io.Clock.real.now(io).toSeconds();
const failing = try upstream_history_repo.windowStats(
&database,
"https://bad.example/dns-query",
now - 3600,
now + 3600,
);
try testing.expectEqual(@as(u64, 1), failing.failures);
try testing.expectEqual(@as(u64, 0), failing.successes);
try testing.expect(failing.last_failure_ts != null);
try testing.expectEqualStrings("Timeout", failing.lastFailureError());
const succeeding = try upstream_history_repo.windowStats(
&database,
"https://good.example/dns-query",
now - 3600,
now + 3600,
);
try testing.expectEqual(@as(u64, 1), succeeding.successes);
try testing.expectEqual(@as(u64, 0), succeeding.failures);
try testing.expectEqual(@as(?i64, null), succeeding.last_failure_ts);
}
test "snapshot reports the counters in pool order" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+299 -200
View File
@@ -6,6 +6,13 @@
//!
//! Unauthenticated and rate-limit exempt, like `/metrics`.
//!
//! **Nothing may degrade the rollup without appearing in the response.** The
//! body is five condition objects and a status computed from exactly their
//! states, so an operator reading it can always name the condition that
//! degraded the box. Anything a counter already records — gated refreshes,
//! disk sample failures, the snapshot generation — stays in `/metrics` and in
//! Diagnostics rather than becoming a sixth hidden input here.
//!
//! `rollup` is pure so the whole degraded matrix is testable without a running
//! server; `handle` only gathers the inputs.
@@ -13,16 +20,39 @@ const std = @import("std");
const disk_monitor = @import("../../storage/disk_monitor.zig");
const http_util = @import("../http_util.zig");
const logger_mod = @import("../../storage/logger.zig");
const metrics = @import("../metrics.zig");
const pause_mod = @import("../../server/pause.zig");
const pool_mod = @import("../../upstream/pool.zig");
const server = @import("../server.zig");
pub const Disk = struct {
/// 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,
/// which `handler.zig` records as the `snapshot_unavailable` provenance.
pub const Protection = struct {
state: []const u8,
free_bytes: u64,
db_bytes: u64,
log_bytes: u64,
sample_failures: u64,
/// The second filtering resumes at. Null for both an indefinite pause and
/// every non-paused state, which the state field already tells apart.
until: ?i64,
};
pub const Upstreams = struct {
state: []const u8,
available: u32,
/// Enabled upstreams: the pool is built from those alone, so this is what
/// `available` is out of.
total: u32,
};
/// Whether Activity can be trusted. `dropped_total` is cumulative and does not
/// decide the state — a drop that happened an hour ago is not a fault now.
pub const QueryHistory = struct {
state: []const u8,
dropped_total: u64,
/// The newest drop, or null while nothing has been dropped. Stamped by a
/// separate atomic from the count, so a reader can momentarily see a
/// non-zero `dropped_total` beside a null here.
last_drop_s: ?i64,
};
/// The diagnostics store's own state, not a summary of what it holds: `state`
@@ -34,21 +64,18 @@ pub const Diagnostics = struct {
active_errors: u32,
};
pub const Upstreams = struct {
available: u32,
total: u32,
pub const Disk = struct {
state: []const u8,
free_bytes: u64,
};
pub const Body = struct {
status: []const u8,
disk: Disk,
protection: Protection,
upstreams: Upstreams,
query_history: QueryHistory,
diagnostics: Diagnostics,
queries_dropped: u64,
writer_failed: bool,
refreshes_gated: u64,
/// Null before the first filter snapshot is published.
snapshot_generation: ?u64,
disk: Disk,
};
/// What the rollup is computed from. Every field has a defined value even when
@@ -56,41 +83,79 @@ pub const Body = struct {
/// server should report: no disk reading, no upstreams, nothing published.
pub const Input = struct {
disk_state: disk_monitor.State = .ok,
disk: disk_monitor.Gauges = .{ .free_bytes = 0, .db_bytes = 0, .log_bytes = 0 },
disk_sample_failures: u64 = 0,
disk_free_bytes: u64 = 0,
upstreams_available: u32 = 0,
upstreams_total: u32 = 0,
queries_dropped: u64 = 0,
last_drop_s: ?i64 = null,
writer_failed: bool = false,
/// Current state, not a count: the upstream-history flush is failing right
/// now. Cleared by the next flush that succeeds (m26 ruling 7).
///
/// `rows_dropped` deliberately does not appear here. It is cumulative, and
/// a rollup that is computed statelessly cannot ask whether a counter grew
/// — so feeding it in would latch `/api/health` to degraded forever after
/// one overflow. Drops surface through the metric and through the API's
/// per-window `complete` instead.
history_flush_failing: bool = false,
/// The logger's disk-gating episode. `losing` is the only state that
/// degrades: it means the gate is holding writes back *and* has already
/// cost rows in the episode that is open now.
gate_episode: logger_mod.GateEpisode = .open,
/// A filter snapshot exists for the query path to evaluate against. The
/// benign default matches every other field here, and `collect` assigns it
/// explicitly for the same reason `diagnostics_present` is assigned there.
snapshot_available: bool = true,
/// `pause.Pause.until` verbatim: 0 not paused, -1 indefinite, otherwise the
/// second filtering resumes at. Raw rather than a decided boolean so the
/// expiry rule stays `pause.Pause`'s and is exercised by these tests.
pause_until: i64 = 0,
/// The clock the pause is compared against.
now_s: i64 = 0,
/// The diagnostics store exists. The benign default matches every other
/// field here — a half-wired `Input` reports a box with nothing wrong — but
/// `collect` must assign it explicitly, because in a serving process an
/// absent store means `Store.init` failed.
diagnostics_present: bool = true,
/// The last diagnostics write failed. Current state, cleared by the next
/// write that succeeds, like `history_flush_failing`.
/// write that succeeds.
diagnostics_write_failed: bool = false,
diagnostics_active_warnings: u32 = 0,
diagnostics_active_errors: u32 = 0,
refreshes_gated: u64 = 0,
snapshot_generation: ?u64 = null,
};
pub const status_ok = "ok";
pub const status_degraded = "degraded";
pub const protection_active = "active";
pub const protection_paused = "paused";
pub const protection_unavailable = "unavailable";
pub const upstreams_ok = "ok";
pub const upstreams_unavailable = "unavailable";
pub const query_history_recording = "recording";
pub const query_history_losing = "losing";
pub const query_history_failed = "failed";
pub const diagnostics_recording = "recording";
pub const diagnostics_unavailable = "unavailable";
pub const disk_ok = "ok";
pub const disk_low = "low";
pub const disk_critical = "critical";
/// Precedence `unavailable` → `paused` → `active`. With no snapshot the pause
/// flag says nothing an operator can act on: filtering is off either way, and
/// resuming would not turn it back on.
pub fn protection(input: Input) Protection {
if (!input.snapshot_available) return .{ .state = protection_unavailable, .until = null };
const state: pause_mod.Pause = .{ .until = .init(input.pause_until) };
if (!state.isPaused(input.now_s)) return .{ .state = protection_active, .until = null };
return .{
.state = protection_paused,
.until = if (input.pause_until > 0) input.pause_until else null,
};
}
pub fn queryHistoryState(input: Input) []const u8 {
if (input.writer_failed) return query_history_failed;
if (input.gate_episode == .losing) return query_history_losing;
return query_history_recording;
}
/// The operational log is not recording — either the store never opened or its
/// writes are failing. Both mean the same thing to an operator: the record of
/// what went wrong is not being kept.
@@ -98,37 +163,51 @@ pub fn diagnosticsUnavailable(input: Input) bool {
return !input.diagnostics_present or input.diagnostics_write_failed;
}
/// Conditions an operator must act on, and every one of them is a fact about
/// now rather than a count of the past: a disk that is filling stops the query
/// log, a pool with nothing available stops resolution, a failed writer means
/// rows are being lost right now, and a failing history flush means the
/// dashboard's upstream numbers are not being recorded. Each clears itself when
/// the underlying condition does.
/// `warn` reads as a log level rather than as a quantity of disk. The monitor
/// keeps its own name; the wire says what an operator sees on the page.
pub fn diskState(state: disk_monitor.State) []const u8 {
return switch (state) {
.ok => disk_ok,
.warn => disk_low,
.critical => disk_critical,
};
}
/// The degrading set, named rather than gestured at: protection `unavailable`,
/// upstreams `unavailable`, query history `losing` or `failed`, diagnostics
/// `unavailable`, disk `low` or `critical`.
///
/// A pause is deliberately not in it. It is an operator's own choice, and a
/// monitor that pages on it would be paging on a button the operator pressed.
pub fn degraded(input: Input) bool {
return input.disk_state != .ok or input.upstreams_available == 0 or
input.writer_failed or input.history_flush_failing or diagnosticsUnavailable(input);
const history = queryHistoryState(input);
return !input.snapshot_available or
input.upstreams_available == 0 or
!std.mem.eql(u8, history, query_history_recording) or
diagnosticsUnavailable(input) or
input.disk_state != .ok;
}
pub fn rollup(input: Input) Body {
return .{
.status = if (degraded(input)) status_degraded else status_ok,
.disk = .{
.state = @tagName(input.disk_state),
.free_bytes = input.disk.free_bytes,
.db_bytes = input.disk.db_bytes,
.log_bytes = input.disk.log_bytes,
.sample_failures = input.disk_sample_failures,
.protection = protection(input),
.upstreams = .{
.state = if (input.upstreams_available == 0) upstreams_unavailable else upstreams_ok,
.available = input.upstreams_available,
.total = input.upstreams_total,
},
.query_history = .{
.state = queryHistoryState(input),
.dropped_total = input.queries_dropped,
.last_drop_s = input.last_drop_s,
},
.upstreams = .{ .available = input.upstreams_available, .total = input.upstreams_total },
.diagnostics = .{
.state = if (diagnosticsUnavailable(input)) diagnostics_unavailable else diagnostics_recording,
.active_warnings = input.diagnostics_active_warnings,
.active_errors = input.diagnostics_active_errors,
},
.queries_dropped = input.queries_dropped,
.writer_failed = input.writer_failed,
.refreshes_gated = input.refreshes_gated,
.snapshot_generation = input.snapshot_generation,
.disk = .{ .state = diskState(input.disk_state), .free_bytes = input.disk_free_bytes },
};
}
@@ -143,10 +222,11 @@ pub fn handle(
pub fn collect(state: *server.WebState, io: std.Io) Input {
var input: Input = .{};
input.now_s = std.Io.Clock.real.now(io).toSeconds();
if (state.monitor) |monitor| {
input.disk_state = monitor.state();
input.disk = monitor.gauges();
input.disk_sample_failures = monitor.sample_failures.load(.monotonic);
input.disk_free_bytes = monitor.gauges().free_bytes;
}
if (state.pool) |pool| {
@@ -160,9 +240,13 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
if (state.logger) |logger| {
input.queries_dropped = logger.queries_dropped.load(.monotonic);
input.last_drop_s = logger.lastDropSeconds();
input.writer_failed = logger.writer_failed.load(.monotonic);
input.gate_episode = logger.gateEpisode();
}
if (state.pause) |paused| input.pause_until = paused.until.load(.monotonic);
// Assigned before the `if`, not inside it: the field's benign default is
// `true`, so the natural `if (state.events) |store|` shape would report an
// absent store as recording — the one case that must degrade.
@@ -174,15 +258,13 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
input.diagnostics_active_errors = counts.errors;
}
if (state.history) |history| {
input.history_flush_failing = history.snapshotStats(io).last_flush_failed;
}
// The generation itself is not in the body — the UI-facing fact is whether
// protection has a snapshot at all, and `/metrics` keeps the number.
input.snapshot_available = false;
if (state.manager) |manager| {
input.refreshes_gated = manager.refreshesGated();
if (manager.acquire(io)) |acquired| {
defer acquired.release(io);
input.snapshot_generation = acquired.snapshot.generation;
input.snapshot_available = true;
}
}
@@ -196,104 +278,175 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
const db = @import("../../storage/db.zig");
const events_mod = @import("../../storage/events.zig");
const migrations = @import("../../storage/migrations.zig");
const history_mod = @import("../../upstream/history.zig");
const logger_mod = @import("../../storage/logger.zig");
const upstream_history_repo = @import("../../storage/repositories/upstream_history_repo.zig");
const testing = std.testing;
/// A box with nothing wrong with it: one upstream up, disk ok, writer alive.
/// A box with nothing wrong with it: one upstream up, disk ok, writer alive,
/// a snapshot published and filtering unpaused.
const healthy: Input = .{
.disk_state = .ok,
.upstreams_available = 1,
.upstreams_total = 1,
.writer_failed = false,
.snapshot_available = true,
};
test "the degraded matrix covers disk state, availability and the writer" {
const cases = [_]struct { input: Input, degraded: bool }{
.{ .input = healthy, .degraded = false },
.{ .input = withDisk(healthy, .warn), .degraded = true },
.{ .input = withDisk(healthy, .critical), .degraded = true },
.{ .input = withAvailable(healthy, 0), .degraded = true },
.{ .input = withWriterFailed(healthy), .degraded = true },
// A failing upstream-history flush is losing the dashboard's numbers
// right now, and it recovers on its own the moment a flush succeeds.
.{ .input = withHistoryFailing(healthy, true), .degraded = true },
.{ .input = withHistoryFailing(healthy, false), .degraded = false },
// The operational log not recording is itself a fault an operator must
// act on: whatever fails next will leave no record of having failed.
.{ .input = withDiagnostics(healthy, false, false), .degraded = true },
.{ .input = withDiagnostics(healthy, true, true), .degraded = true },
.{ .input = withDiagnostics(healthy, true, false), .degraded = false },
// Two faults at once still report one status.
.{ .input = withWriterFailed(withDisk(healthy, .critical)), .degraded = true },
// Some upstreams down is not degraded while one still answers.
.{ .input = .{ .upstreams_available = 1, .upstreams_total = 3 }, .degraded = false },
};
fn with(input: Input, comptime field: []const u8, value: anytype) Input {
var out = input;
@field(out, field) = value;
return out;
}
for (cases, 0..) |case, i| {
errdefer std.debug.print("case {d}\n", .{i});
try testing.expectEqual(case.degraded, degraded(case.input));
try testing.expectEqualStrings(
if (case.degraded) status_degraded else status_ok,
rollup(case.input).status,
);
test "each degrading condition degrades on its own and says so in the body" {
try testing.expectEqualStrings(status_ok, rollup(healthy).status);
{
const body = rollup(with(healthy, "snapshot_available", false));
try testing.expectEqualStrings(status_degraded, body.status);
try testing.expectEqualStrings(protection_unavailable, body.protection.state);
}
{
const body = rollup(with(healthy, "upstreams_available", @as(u32, 0)));
try testing.expectEqualStrings(status_degraded, body.status);
try testing.expectEqualStrings(upstreams_unavailable, body.upstreams.state);
}
{
const body = rollup(with(healthy, "gate_episode", .losing));
try testing.expectEqualStrings(status_degraded, body.status);
try testing.expectEqualStrings(query_history_losing, body.query_history.state);
}
{
const body = rollup(with(healthy, "writer_failed", true));
try testing.expectEqualStrings(status_degraded, body.status);
try testing.expectEqualStrings(query_history_failed, body.query_history.state);
}
{
const body = rollup(with(healthy, "diagnostics_present", false));
try testing.expectEqualStrings(status_degraded, body.status);
try testing.expectEqualStrings(diagnostics_unavailable, body.diagnostics.state);
}
{
const body = rollup(with(healthy, "diagnostics_write_failed", true));
try testing.expectEqualStrings(status_degraded, body.status);
try testing.expectEqualStrings(diagnostics_unavailable, body.diagnostics.state);
}
for ([_]struct { disk_monitor.State, []const u8 }{
.{ .warn, disk_low },
.{ .critical, disk_critical },
}) |case| {
const body = rollup(with(healthy, "disk_state", case[0]));
try testing.expectEqualStrings(status_degraded, body.status);
try testing.expectEqualStrings(case[1], body.disk.state);
}
}
fn withDisk(input: Input, state: disk_monitor.State) Input {
var out = input;
out.disk_state = state;
return out;
test "conditions that are not faults leave the status ok" {
// Some upstreams down while one still answers.
const partial = rollup(.{ .upstreams_available = 1, .upstreams_total = 3 });
try testing.expectEqualStrings(status_ok, partial.status);
try testing.expectEqualStrings(upstreams_ok, partial.upstreams.state);
// A gate holding writes that has not cost a row yet.
const holding = rollup(with(healthy, "gate_episode", .gated));
try testing.expectEqualStrings(status_ok, holding.status);
try testing.expectEqualStrings(query_history_recording, holding.query_history.state);
// Drops that already happened. The counter is cumulative and the rollup is
// stateless, so feeding it in would latch the box to degraded forever.
var dropped = with(healthy, "queries_dropped", @as(u64, 9));
dropped.last_drop_s = 1_700_000_000;
const body = rollup(dropped);
try testing.expectEqualStrings(status_ok, body.status);
try testing.expectEqualStrings(query_history_recording, body.query_history.state);
try testing.expectEqual(@as(u64, 9), body.query_history.dropped_total);
try testing.expectEqual(@as(?i64, 1_700_000_000), body.query_history.last_drop_s);
// Open diagnostics episodes are what the box is doing, not a fault of the
// log that recorded them.
var open = healthy;
open.diagnostics_active_warnings = 3;
open.diagnostics_active_errors = 1;
const with_episodes = rollup(open);
try testing.expectEqualStrings(status_ok, with_episodes.status);
try testing.expectEqualStrings(diagnostics_recording, with_episodes.diagnostics.state);
try testing.expectEqual(@as(u32, 3), with_episodes.diagnostics.active_warnings);
try testing.expectEqual(@as(u32, 1), with_episodes.diagnostics.active_errors);
}
fn withAvailable(input: Input, available: u32) Input {
var out = input;
out.upstreams_available = available;
return out;
test "a pause is surfaced, never alarmed, and an expired one is over" {
var indefinite = healthy;
indefinite.pause_until = -1;
indefinite.now_s = 1_000;
const forever = rollup(indefinite);
try testing.expectEqualStrings(status_ok, forever.status);
try testing.expectEqualStrings(protection_paused, forever.protection.state);
try testing.expectEqual(@as(?i64, null), forever.protection.until);
var timed = healthy;
timed.pause_until = 1_060;
timed.now_s = 1_000;
const live = rollup(timed);
try testing.expectEqualStrings(status_ok, live.status);
try testing.expectEqualStrings(protection_paused, live.protection.state);
try testing.expectEqual(@as(?i64, 1_060), live.protection.until);
// The stored second is when filtering is back on, so at it the pause is over.
timed.now_s = 1_060;
const expired = rollup(timed);
try testing.expectEqualStrings(protection_active, expired.protection.state);
try testing.expectEqual(@as(?i64, null), expired.protection.until);
}
fn withWriterFailed(input: Input) Input {
var out = input;
out.writer_failed = true;
return out;
test "protection unavailable wins over a live pause" {
var both = healthy;
both.snapshot_available = false;
both.pause_until = -1;
const body = rollup(both);
try testing.expectEqualStrings(protection_unavailable, body.protection.state);
try testing.expectEqual(@as(?i64, null), body.protection.until);
try testing.expectEqualStrings(status_degraded, body.status);
}
fn withHistoryFailing(input: Input, failing: bool) Input {
var out = input;
out.history_flush_failing = failing;
return out;
test "a failed writer outranks a losing gate" {
var both = healthy;
both.writer_failed = true;
both.gate_episode = .losing;
try testing.expectEqualStrings(query_history_failed, rollup(both).query_history.state);
}
fn withDiagnostics(input: Input, present: bool, write_failed: bool) Input {
var out = input;
out.diagnostics_present = present;
out.diagnostics_write_failed = write_failed;
return out;
test "two faults at once still report one status" {
var both = with(healthy, "disk_state", disk_monitor.State.critical);
both.writer_failed = true;
try testing.expectEqualStrings(status_degraded, rollup(both).status);
}
test "the diagnostics block reports the state and the open counts" {
const recording = rollup(.{
.upstreams_available = 1,
.diagnostics_active_warnings = 3,
.diagnostics_active_errors = 1,
test "the body reports every input verbatim" {
const body = rollup(.{
.disk_state = .warn,
.disk_free_bytes = 100,
.upstreams_available = 2,
.upstreams_total = 4,
.queries_dropped = 9,
.last_drop_s = 1_700_000_000,
.snapshot_available = true,
});
try testing.expectEqualStrings(diagnostics_recording, recording.diagnostics.state);
try testing.expectEqual(@as(u32, 3), recording.diagnostics.active_warnings);
try testing.expectEqual(@as(u32, 1), recording.diagnostics.active_errors);
// Open episodes are what the box is doing, not a fault of the log: they do
// not degrade on their own.
try testing.expectEqualStrings(status_ok, recording.status);
// Failing writes: the counts are whatever was last read, and the state is
// the honest one.
const failing = rollup(.{ .upstreams_available = 1, .diagnostics_write_failed = true });
try testing.expectEqualStrings(diagnostics_unavailable, failing.diagnostics.state);
try testing.expectEqualStrings(status_degraded, failing.status);
try testing.expectEqualStrings(status_degraded, body.status);
try testing.expectEqualStrings(disk_low, body.disk.state);
try testing.expectEqual(@as(u64, 100), body.disk.free_bytes);
try testing.expectEqual(@as(u32, 2), body.upstreams.available);
try testing.expectEqual(@as(u32, 4), body.upstreams.total);
try testing.expectEqualStrings(upstreams_ok, body.upstreams.state);
try testing.expectEqual(@as(u64, 9), body.query_history.dropped_total);
try testing.expectEqual(@as(?i64, 1_700_000_000), body.query_history.last_drop_s);
try testing.expectEqualStrings(protection_active, body.protection.state);
}
const absent = rollup(.{ .upstreams_available = 1, .diagnostics_present = false });
try testing.expectEqualStrings(diagnostics_unavailable, absent.diagnostics.state);
try testing.expectEqualStrings(status_degraded, absent.status);
test "an unstamped drop time serializes as null, not as zero" {
var buffer: [1024]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buffer);
try std.json.Stringify.value(rollup(.{}), .{}, &writer);
try testing.expect(std.mem.containsAtLeast(u8, writer.buffered(), 1, "\"last_drop_s\":null"));
try testing.expect(std.mem.containsAtLeast(u8, writer.buffered(), 1, "\"until\":null"));
}
test "collect reports an absent store as unavailable rather than as recording" {
@@ -307,6 +460,10 @@ test "collect reports an absent store as unavailable rather than as recording" {
const absent = collect(&state, io);
try testing.expect(!absent.diagnostics_present);
try testing.expectEqualStrings(diagnostics_unavailable, rollup(absent).diagnostics.state);
// And a bare state has no snapshot either, which is the honest reading of a
// process that has published nothing.
try testing.expect(!absent.snapshot_available);
try testing.expectEqualStrings(protection_unavailable, rollup(absent).protection.state);
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
@@ -325,76 +482,7 @@ test "collect reports an absent store as unavailable rather than as recording" {
try testing.expectEqualStrings(diagnostics_recording, rollup(present).diagnostics.state);
}
test "a history overflow that already happened does not degrade the rollup" {
// `rows_dropped` is cumulative and the rollup is stateless, so the only
// thing it could do with a drop count is latch on it. The accumulator's
// drops reach an operator through `/metrics` and through the per-window
// `complete` flag, and never through this.
const dropped: Input = .{
.upstreams_available = 1,
.upstreams_total = 1,
.history_flush_failing = false,
};
try testing.expect(!degraded(dropped));
try testing.expectEqualStrings(status_ok, rollup(dropped).status);
}
test "collect reads the accumulator's current flush state" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const acc = try testing.allocator.create(history_mod.Accumulator);
defer testing.allocator.destroy(acc);
acc.* = .init;
var state: server.WebState = .{ .gpa = testing.allocator, .history = acc };
try testing.expect(!collect(&state, io).history_flush_failing);
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
acc.recordSuccess(io, "https://a.example", 60);
// No schema in this database, so the real write fails and the flag is set
// by the production path rather than by a test poking a field.
acc.flushOnce(io, &database, upstream_history_repo.flush);
try testing.expect(collect(&state, io).history_flush_failing);
try testing.expectEqualStrings("degraded", rollup(collect(&state, io)).status);
}
test "the body reports every input verbatim" {
const body = rollup(.{
.disk_state = .warn,
.disk = .{ .free_bytes = 100, .db_bytes = 20, .log_bytes = 3 },
.disk_sample_failures = 2,
.upstreams_available = 2,
.upstreams_total = 4,
.queries_dropped = 9,
.writer_failed = false,
.refreshes_gated = 1,
.snapshot_generation = 12,
});
try testing.expectEqualStrings("degraded", body.status);
try testing.expectEqualStrings("warn", body.disk.state);
try testing.expectEqual(@as(u64, 100), body.disk.free_bytes);
try testing.expectEqual(@as(u64, 20), body.disk.db_bytes);
try testing.expectEqual(@as(u64, 3), body.disk.log_bytes);
try testing.expectEqual(@as(u64, 2), body.disk.sample_failures);
try testing.expectEqual(@as(u32, 2), body.upstreams.available);
try testing.expectEqual(@as(u32, 4), body.upstreams.total);
try testing.expectEqual(@as(u64, 9), body.queries_dropped);
try testing.expectEqual(@as(u64, 1), body.refreshes_gated);
try testing.expectEqual(@as(?u64, 12), body.snapshot_generation);
}
test "an unpublished snapshot serializes as null, not as zero" {
var buffer: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buffer);
try std.json.Stringify.value(rollup(.{}), .{}, &writer);
try testing.expect(std.mem.containsAtLeast(u8, writer.buffered(), 1, "\"snapshot_generation\":null"));
}
test "collect reads the logger's counters and reports a bare state as degraded" {
test "collect reads the logger's counters and the pause flag" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -402,14 +490,25 @@ test "collect reads the logger's counters and reports a bare state as degraded"
var queue_buf: [2]logger_mod.Entry = undefined;
var query_logger: logger_mod.Logger = .init(.{}, &queue_buf);
query_logger.queries_dropped.store(4, .monotonic);
query_logger.last_drop_s.store(1_700_000_000, .monotonic);
query_logger.writer_failed.store(true, .monotonic);
var state: server.WebState = .{ .gpa = testing.allocator, .logger = &query_logger };
var paused: pause_mod.Pause = .{};
paused.pauseFor(0, null);
var state: server.WebState = .{
.gpa = testing.allocator,
.logger = &query_logger,
.pause = &paused,
};
const input = collect(&state, io);
try testing.expectEqual(@as(u64, 4), input.queries_dropped);
try testing.expectEqual(@as(?i64, 1_700_000_000), input.last_drop_s);
try testing.expect(input.writer_failed);
try testing.expectEqual(@as(i64, -1), input.pause_until);
try testing.expectEqual(@as(u32, 0), input.upstreams_total);
try testing.expectEqual(@as(?u64, null), input.snapshot_generation);
try testing.expectEqualStrings("degraded", rollup(input).status);
try testing.expectEqualStrings(status_degraded, rollup(input).status);
// No snapshot manager either, so protection outranks the pause here too.
try testing.expectEqualStrings(protection_unavailable, rollup(input).protection.state);
}
+36 -6
View File
@@ -126,8 +126,6 @@ pub fn list(
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
_ = io;
var buffers: Buffers = .{};
const filter = parseFilter(request.query, &buffers) catch |err| {
return http_util.respondError(request, .bad_request, message(err));
@@ -136,7 +134,7 @@ pub fn list(
const database = state.querylog_db orelse
return http_util.respondError(request, .service_unavailable, "query log unavailable");
const result = page(database, request.arena, filter) catch |err| {
const result = readPage(state, io, database, request.arena, filter) catch |err| {
// The one thing this handler logs: a database fault is a property of
// the box, not of the request, and the client is told nothing about it.
log.warn("query log read failed: {s}", .{@errorName(err)});
@@ -156,12 +154,10 @@ pub fn detail(
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
_ = io;
const database = state.querylog_db orelse
return http_util.respondError(request, .service_unavailable, "query log unavailable");
const row = queries_repo.detailById(database, request.arena, request.id.?) catch |err| {
const row = detailRow(state, io, database, request.arena, request.id.?) catch |err| {
log.warn("query log read failed: {s}", .{@errorName(err)});
return http_util.respondError(request, .internal_server_error, "internal error");
};
@@ -172,6 +168,40 @@ pub fn detail(
return http_util.respondJson(request, .ok, provenance_view.fromDetail(found), &.{});
}
/// The rows and the coverage watermark come from one database state, so a
/// prune between them cannot tag pre-prune rows with a post-prune
/// `available_since`.
fn readPage(
state: *server.WebState,
io: std.Io,
database: *db.Db,
arena: Allocator,
filter: queries_repo.QueryFilter,
) db.Error!Page {
var scope = try server.QuerylogRead.open(state, io, database);
errdefer scope.abort();
const result = try page(database, arena, filter);
try scope.commit();
return result;
}
/// One row, read under the shared lock. There is nothing to keep consistent
/// with a second statement here; the lock is what keeps this read out of
/// another response's open transaction.
fn detailRow(
state: *server.WebState,
io: std.Io,
database: *db.Db,
arena: Allocator,
id: i64,
) db.Error!?queries_repo.QueryDetail {
var scope = try server.QuerylogRead.open(state, io, database);
errdefer scope.abort();
const row = try queries_repo.detailById(database, arena, id);
try scope.commit();
return row;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+231 -23
View File
@@ -1,8 +1,16 @@
//! `GET /api/stats` and `GET /api/stats/timeseries` (ruling 13).
//! The five period endpoints: `GET /api/stats` and `/api/stats/timeseries`
//! (ruling 13), and `/api/stats/types`, `/api/stats/routes` and
//! `/api/stats/clients` (milestone 30).
//!
//! One period grammar, four widths, and one window shared by both endpoints:
//! the totals cover exactly the span the chart draws, so a dashboard cannot
//! show a sum that disagrees with the bars above it.
//! One period grammar, four widths, and one window shared by all five: a
//! request for the same period gets the same `since`/`until` from every
//! endpoint, so the totals describe exactly the span the charts draw rather
//! than a neighbouring one.
//!
//! That is window coherence, not identical counts. Each endpoint is its own
//! request against its own snapshot, so queries logged between two of them move
//! one panel and not the other. Only a box with nothing writing to it — a test
//! — can expect the breakdowns to sum to the totals exactly.
//!
//! Buckets are aligned to the UTC grid, not to the moment of the request. Every
//! width divides a day, so flooring the current time to a multiple of the width
@@ -10,7 +18,19 @@
//! requests a second apart return the same bucket starts. The last bucket is
//! the one in progress; it fills as the period runs.
//!
//! The aggregates run on the web task's own query-log connection (m7 ruling 21).
//! The aggregates run on the web task's own query-log connection (m7 ruling 21),
//! which every connection task shares. SQLite's serialized mode makes one call
//! safe; it does not make a transaction safe, so `WebState.querylog_lock` covers
//! the whole read and a second BEGIN can never land inside the first. Each
//! response takes one deferred read transaction, so its aggregate and the
//! `coverage` beside it describe one database state: retention cannot prune
//! between them and hand a client pre-prune rows tagged with a post-prune
//! watermark. Deferred, not `db.Tx`'s BEGIN IMMEDIATE, which would stall the
//! logger and retention behind an HTTP response.
//!
//! The lock is released before the response is written: the body is already
//! built in the request arena, and holding a database lock across a socket
//! write would let one slow client serialize every other reader.
const std = @import("std");
@@ -97,7 +117,6 @@ pub const TotalsBody = struct {
until: i64,
queries: u64,
blocked: u64,
cached: u64,
clients: u64,
avg_response_time_us: ?i64,
/// Judged against `since`, which is the window this body reports on — so a
@@ -115,6 +134,135 @@ pub const TimeseriesBody = struct {
coverage: coverage.Coverage,
};
pub const TypesBody = struct {
period: []const u8,
since: i64,
until: i64,
types: []const queries_repo.TypeCount,
coverage: coverage.Coverage,
};
pub const RoutesBody = struct {
period: []const u8,
since: i64,
until: i64,
routes: []const queries_repo.RouteCount,
coverage: coverage.Coverage,
};
pub const ClientsBody = struct {
period: []const u8,
since: i64,
until: i64,
bucket_seconds: u32,
clients: []const queries_repo.ClientSeries,
other: []const u64,
coverage: coverage.Coverage,
};
/// Everything one response reads from the query log, so the caller can end the
/// transaction and drop the lock before it serializes anything.
fn Read(comptime T: type) type {
return struct {
data: T,
coverage: coverage.Coverage,
};
}
const ReadScope = server.QuerylogRead;
fn readTotals(
state: *server.WebState,
io: std.Io,
database: *db.Db,
span: Window,
) db.Error!Read(queries_repo.StatsTotals) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const read: Read(queries_repo.StatsTotals) = .{
.data = try queries_repo.statsTotals(database, span.since, span.until),
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
fn readTimeseries(
state: *server.WebState,
io: std.Io,
database: *db.Db,
span: Window,
out: []queries_repo.Bucket,
) db.Error!Read(usize) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const read: Read(usize) = .{
.data = try queries_repo.timeseries(database, span.since, span.bucket_seconds, out),
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
fn readTypes(
state: *server.WebState,
io: std.Io,
database: *db.Db,
arena: std.mem.Allocator,
span: Window,
) db.Error!Read([]const queries_repo.TypeCount) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const list = try queries_repo.statsTypes(database, arena, span.since, span.until);
const read: Read([]const queries_repo.TypeCount) = .{
.data = list.items,
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
fn readRoutes(
state: *server.WebState,
io: std.Io,
database: *db.Db,
arena: std.mem.Allocator,
span: Window,
) db.Error!Read([]const queries_repo.RouteCount) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const list = try queries_repo.statsRoutes(database, arena, span.since, span.until);
const read: Read([]const queries_repo.RouteCount) = .{
.data = list.items,
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
fn readClients(
state: *server.WebState,
io: std.Io,
database: *db.Db,
arena: std.mem.Allocator,
span: Window,
) db.Error!Read(queries_repo.ClientsBreakdown) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const read: Read(queries_repo.ClientsBreakdown) = .{
.data = try queries_repo.statsClients(
database,
arena,
span.since,
span.bucket_seconds,
span.bucket_count,
),
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
pub fn totals(
state: *server.WebState,
io: std.Io,
@@ -124,23 +272,19 @@ pub fn totals(
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
const result = queries_repo.statsTotals(database, span.since, span.until) catch |err| {
const read = readTotals(state, io, database, span) catch |err| {
return internal(request, "stats totals", err);
};
const covered = coverage.read(database, span.since) catch |err| {
return internal(request, "stats coverage", err);
};
return http_util.respondJson(request, .ok, TotalsBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.queries = result.queries,
.blocked = result.blocked,
.cached = result.cached,
.clients = result.distinct_clients,
.avg_response_time_us = result.avg_response_time_us,
.coverage = covered,
.queries = read.data.queries,
.blocked = read.data.blocked,
.clients = read.data.distinct_clients,
.avg_response_time_us = read.data.avg_response_time_us,
.coverage = read.coverage,
}, &.{});
}
@@ -155,20 +299,85 @@ pub fn timeseries(
var buckets: [max_buckets]queries_repo.Bucket = undefined;
const out = buckets[0..span.bucket_count];
const written = queries_repo.timeseries(database, span.since, span.bucket_seconds, out) catch |err| {
const read = readTimeseries(state, io, database, span, out) catch |err| {
return internal(request, "stats timeseries", err);
};
const covered = coverage.read(database, span.since) catch |err| {
return internal(request, "stats coverage", err);
};
return http_util.respondJson(request, .ok, TimeseriesBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.bucket_seconds = span.bucket_seconds,
.buckets = out[0..written],
.coverage = covered,
.buckets = out[0..read.data],
.coverage = read.coverage,
}, &.{});
}
pub fn types(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
const read = readTypes(state, io, database, request.arena, span) catch |err| {
return internal(request, "stats types", err);
};
return http_util.respondJson(request, .ok, TypesBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.types = read.data,
.coverage = read.coverage,
}, &.{});
}
pub fn routes(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
const read = readRoutes(state, io, database, request.arena, span) catch |err| {
return internal(request, "stats routes", err);
};
return http_util.respondJson(request, .ok, RoutesBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.routes = read.data,
.coverage = read.coverage,
}, &.{});
}
pub fn clients(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
const read = readClients(state, io, database, request.arena, span) catch |err| {
return internal(request, "stats clients", err);
};
return http_util.respondJson(request, .ok, ClientsBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.bucket_seconds = span.bucket_seconds,
.clients = read.data.clients,
.other = read.data.other,
.coverage = read.coverage,
}, &.{});
}
@@ -321,7 +530,6 @@ test "the totals and the buckets agree over the same window" {
const result = try queries_repo.statsTotals(&database, span.since, span.until);
try testing.expectEqual(@as(u64, 3), result.queries);
try testing.expectEqual(@as(u64, 1), result.blocked);
try testing.expectEqual(@as(u64, 1), result.cached);
try testing.expectEqual(@as(u64, 1), result.distinct_clients);
try testing.expectEqual(@as(?i64, 1000), result.avg_response_time_us);
-585
View File
@@ -1,585 +0,0 @@
//! `GET /api/upstream/health?period=` — the pool's upstreams over the window
//! the dashboard's period picker selected (milestone-26 ruling 6).
//!
//! Two kinds of fact, kept apart on the wire because they answer different
//! questions. `enabled`/`available` are live routing state, read from the pool
//! under its mutex: what the resolver would do with this upstream right now.
//! Everything under `period` is history, aggregated out of `upstream_minute`
//! over `[since, until)` — the same window `/api/stats` reports, so a page
//! cannot show a rate that disagrees with the chart beside it.
//!
//! Nothing here reads a process-lifetime counter. The lifetime totals, the
//! last-32-exchange window and the consecutive-failure count still live in
//! `health.State` for routing and in `/metrics`; they are not this response's
//! business, because a number that starts at process start cannot be scoped to
//! a period and a dashboard that shows one beside a picker lies about it.
//!
//! The aggregation runs on the web task's own query-log connection (m7 ruling
//! 21) and this file owns no SQL: `upstream_history_repo` does.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../../storage/db.zig");
const history_mod = @import("../../upstream/history.zig");
const http_util = @import("../http_util.zig");
const metrics = @import("../metrics.zig");
const pool_mod = @import("../../upstream/pool.zig");
const server = @import("../server.zig");
const stats = @import("stats.zig");
const upstream_history_repo = @import("../../storage/repositories/upstream_history_repo.zig");
const log = std.log.scoped(.web_upstream_health);
/// One upstream's outcomes inside the selected window.
pub const PeriodStats = struct {
attempts: u64,
successes: u64,
failures: u64,
/// Null when `attempts == 0`. No observations is not perfect reliability,
/// and a `100.0%` from an idle upstream is the exact misreading this
/// milestone exists to remove.
success_rate: ?f32,
/// The newest failure inside the window, on the wall clock the minute rows
/// are stamped with. Null when the window holds no failure, even if the
/// upstream failed before it.
last_failure_at: ?i64,
/// The error name belonging to `last_failure_at`; null exactly when it is.
last_failure_error: ?[]const u8,
};
pub const Upstream = struct {
url: []const u8,
/// Live: configuration, not history.
enabled: bool,
/// Live: false while the upstream is backing off.
available: bool,
period: PeriodStats,
};
pub const Body = struct {
period: []const u8,
since: i64,
until: i64,
available: u32,
total: u32,
/// See `isComplete`.
complete: bool,
upstreams: []const Upstream,
};
/// The same text `/api/stats` sends (`stats.zig`'s `badPeriod`). One period
/// grammar serves the whole dashboard, so the two routes must not disagree
/// about what a typo means.
pub const bad_period_message = "period must be one of 1h, 24h, 7d, 30d";
pub fn handle(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const period = stats.periodParam(request.query) catch
return http_util.respondError(request, .bad_request, bad_period_message);
const pool = state.pool orelse
return http_util.respondError(request, .service_unavailable, "no upstream pool");
const database = state.querylog_db orelse
return http_util.respondError(request, .service_unavailable, "query log unavailable");
const now = std.Io.Clock.real.now(io).toSeconds();
const body = collect(request.arena, io, pool, state.history, database, period, now) catch |err| {
if (err == error.OutOfMemory) return error.OutOfMemory;
// A failed aggregate is a fault in the box, not a property of the
// request (ruling 8, PLAN §19).
log.warn("upstream health window failed: {s}", .{@errorName(err)});
return http_util.respondError(request, .internal_server_error, "internal error");
};
return http_util.respondJson(request, .ok, body, &.{});
}
/// `db.Error` already carries `OutOfMemory`, so the arena's failures and
/// SQLite's share one set.
pub const Error = Allocator.Error || db.Error;
pub fn collect(
arena: Allocator,
io: std.Io,
pool: *pool_mod.Pool,
history: ?*history_mod.Accumulator,
database: *db.Db,
period: stats.Period,
now_unix: i64,
) Error!Body {
const span = stats.window(period, now_unix);
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
const count = metrics.poolSnapshot(pool, io, &raw);
const out = try arena.alloc(Upstream, count);
var available: u32 = 0;
for (raw[0..count], out) |entry, *slot| {
if (entry.available) available += 1;
// Rows come from the current pool only: an upstream deleted from the
// configuration keeps its history in storage until retention takes it,
// and nothing joins it back into this response.
const window_stats = try upstream_history_repo.windowStats(
database,
entry.url,
span.since,
span.until,
);
slot.* = .{
.url = try arena.dupe(u8, entry.url),
.enabled = entry.enabled,
.available = entry.available,
.period = .{
.attempts = window_stats.attempts,
.successes = window_stats.successes,
.failures = window_stats.failures,
.success_rate = successRate(window_stats),
.last_failure_at = window_stats.last_failure_ts,
// `WindowStats` carries its error name by value, in storage this
// loop is done with as soon as the iteration ends. The copy into
// the arena is what keeps the response from pointing at bytes
// the next upstream's row overwrites.
.last_failure_error = if (window_stats.last_failure_ts == null)
null
else
try arena.dupe(u8, window_stats.lastFailureError()),
},
};
}
return .{
.period = period.label(),
.since = span.since,
.until = span.until,
.available = available,
.total = @intCast(count),
.complete = isComplete(history, io, span.since),
.upstreams = out,
};
}
fn successRate(window_stats: upstream_history_repo.WindowStats) ?f32 {
if (window_stats.attempts == 0) return null;
const successes: f32 = @floatFromInt(window_stats.successes);
const attempts: f32 = @floatFromInt(window_stats.attempts);
return successes / attempts;
}
/// Per-window and stateless (ruling 6): false iff capacity has cost this
/// process a minute that falls inside the window. A window that starts after
/// the newest such minute is complete again, so one historical overflow does
/// not mark every later response.
///
/// It says nothing about the newest outcomes, which may not have flushed yet,
/// and nothing about an unclean shutdown, which is not detectable here — the
/// openapi description spells both out.
fn isComplete(history: ?*history_mod.Accumulator, io: std.Io, since: i64) bool {
const accumulator = history orelse return true;
const dropped = accumulator.snapshotStats(io).last_drop_minute orelse return true;
return dropped < since;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const querylog_schema = @import("../../storage/querylog_schema.zig");
const transport = @import("../../upstream/transport.zig");
const testing = std.testing;
/// The client is never called: every test here reads health, not answers.
fn testEntry(url: []const u8, enabled: bool) pool_mod.Entry {
return .{
.endpoint = transport.Endpoint.parse(url) catch unreachable,
.client = .{ .ptr = undefined, .exchangeFn = undefined },
.priority = 1,
.enabled = enabled,
.health = .init,
};
}
fn testPool(entries: []pool_mod.Entry) pool_mod.Pool {
return .init(entries, .{}, .{
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(100), .clock = .awake },
}, 1);
}
fn openLog() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
try database.exec(querylog_schema.ddl);
return database;
}
const url_a = "https://a.test/dns-query";
const url_b = "https://b.test/dns-query";
/// A minute-aligned instant, so a window derived from it lands on round
/// numbers the assertions below can name.
const aligned_now: i64 = 1_699_999_980;
test "the window sums the minutes inside it and nothing outside" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const span = stats.window(.@"1h", aligned_now);
try upstream_history_repo.flush(&database, &.{
// One minute before the window.
.{ .url = url_a, .minute_ts = span.since - 60, .successes = 100, .failures = 100, .last_failure_ts = span.since - 30, .last_error = "Outside" },
.{ .url = url_a, .minute_ts = span.since, .successes = 3, .failures = 1, .last_failure_ts = span.since + 10, .last_error = "Timeout" },
.{ .url = url_a, .minute_ts = span.until - 60, .successes = 5, .failures = 0, .last_failure_ts = null, .last_error = "" },
// The window's exclusive end.
.{ .url = url_a, .minute_ts = span.until, .successes = 200, .failures = 200, .last_failure_ts = span.until + 5, .last_error = "After" },
});
var entries = [_]pool_mod.Entry{testEntry(url_a, true)};
var pool = testPool(&entries);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const body = try collect(arena.allocator(), io, &pool, null, &database, .@"1h", aligned_now);
try testing.expectEqualStrings("1h", body.period);
try testing.expectEqual(span.since, body.since);
try testing.expectEqual(span.until, body.until);
try testing.expectEqual(@as(u32, 1), body.total);
try testing.expectEqual(@as(u32, 1), body.available);
const period = body.upstreams[0].period;
try testing.expectEqual(@as(u64, 8), period.successes);
try testing.expectEqual(@as(u64, 1), period.failures);
try testing.expectEqual(@as(u64, 9), period.attempts);
try testing.expectEqual(@as(?i64, span.since + 10), period.last_failure_at);
try testing.expectEqualStrings("Timeout", period.last_failure_error.?);
}
test "an upstream with no attempts in the window reports null, never a perfect rate" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const span = stats.window(.@"1h", aligned_now);
// Only `b` has history, and only outside the window.
try upstream_history_repo.flush(&database, &.{
.{ .url = url_b, .minute_ts = span.since - 600, .successes = 4, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
var entries = [_]pool_mod.Entry{ testEntry(url_a, true), testEntry(url_b, true) };
var pool = testPool(&entries);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const body = try collect(arena.allocator(), io, &pool, null, &database, .@"1h", aligned_now);
for (body.upstreams) |upstream| {
try testing.expectEqual(@as(u64, 0), upstream.period.attempts);
try testing.expectEqual(@as(u64, 0), upstream.period.successes);
try testing.expectEqual(@as(u64, 0), upstream.period.failures);
try testing.expectEqual(@as(?f32, null), upstream.period.success_rate);
try testing.expectEqual(@as(?i64, null), upstream.period.last_failure_at);
try testing.expectEqual(@as(?[]const u8, null), upstream.period.last_failure_error);
}
}
test "the success rate is the window's own, not a lifetime one" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const span = stats.window(.@"1h", aligned_now);
try upstream_history_repo.flush(&database, &.{
// A clean past that a lifetime rate would average into the window.
.{ .url = url_a, .minute_ts = span.since - 600, .successes = 1000, .failures = 0, .last_failure_ts = null, .last_error = "" },
.{ .url = url_a, .minute_ts = span.since, .successes = 1, .failures = 3, .last_failure_ts = span.since + 1, .last_error = "Timeout" },
});
var entries = [_]pool_mod.Entry{testEntry(url_a, true)};
var pool = testPool(&entries);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const body = try collect(arena.allocator(), io, &pool, null, &database, .@"1h", aligned_now);
try testing.expectEqual(@as(?f32, 0.25), body.upstreams[0].period.success_rate);
}
test "the newest failure inside the window wins over an older one outside it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const span = stats.window(.@"1h", aligned_now);
try upstream_history_repo.flush(&database, &.{
.{ .url = url_a, .minute_ts = span.since - 120, .successes = 0, .failures = 1, .last_failure_ts = span.since - 100, .last_error = "Older" },
.{ .url = url_a, .minute_ts = span.since, .successes = 0, .failures = 1, .last_failure_ts = span.since + 5, .last_error = "Newer" },
// A later minute with no failure at all must not blank the error.
.{ .url = url_a, .minute_ts = span.since + 60, .successes = 2, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
var entries = [_]pool_mod.Entry{testEntry(url_a, true)};
var pool = testPool(&entries);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const body = try collect(arena.allocator(), io, &pool, null, &database, .@"1h", aligned_now);
try testing.expectEqual(@as(?i64, span.since + 5), body.upstreams[0].period.last_failure_at);
try testing.expectEqualStrings("Newer", body.upstreams[0].period.last_failure_error.?);
}
test "two upstreams keep their own last-failure errors" {
// The by-value `WindowStats` buffer is reused per iteration, so a response
// that borrowed it would show the second upstream's error on the first, or
// point at stack storage that is gone by the time it is serialized.
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const span = stats.window(.@"1h", aligned_now);
try upstream_history_repo.flush(&database, &.{
.{ .url = url_a, .minute_ts = span.since, .successes = 1, .failures = 1, .last_failure_ts = span.since + 1, .last_error = "ConnectFailed" },
.{ .url = url_b, .minute_ts = span.since, .successes = 0, .failures = 2, .last_failure_ts = span.since + 2, .last_error = "TlsHandshakeFailed" },
});
var entries = [_]pool_mod.Entry{ testEntry(url_a, true), testEntry(url_b, true) };
var pool = testPool(&entries);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const body = try collect(arena.allocator(), io, &pool, null, &database, .@"1h", aligned_now);
try testing.expectEqual(@as(usize, 2), body.upstreams.len);
try testing.expectEqualStrings(url_a, body.upstreams[0].url);
try testing.expectEqualStrings("ConnectFailed", body.upstreams[0].period.last_failure_error.?);
try testing.expectEqualStrings(url_b, body.upstreams[1].url);
try testing.expectEqualStrings("TlsHandshakeFailed", body.upstreams[1].period.last_failure_error.?);
// Serializing after every row is read is what a real response does; the
// texts must still be the ones their own rows carried.
var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
defer allocating.deinit();
try std.json.Stringify.value(body, .{}, &allocating.writer);
const text = allocating.written();
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"last_failure_error\":\"ConnectFailed\""));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"last_failure_error\":\"TlsHandshakeFailed\""));
}
test "a disabled upstream is not counted available and still gets its window" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const span = stats.window(.@"24h", aligned_now);
try upstream_history_repo.flush(&database, &.{
.{ .url = url_b, .minute_ts = span.since, .successes = 2, .failures = 0, .last_failure_ts = null, .last_error = "" },
});
var entries = [_]pool_mod.Entry{ testEntry(url_a, true), testEntry(url_b, false) };
var pool = testPool(&entries);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const body = try collect(arena.allocator(), io, &pool, null, &database, .@"24h", aligned_now);
try testing.expectEqual(@as(u32, 2), body.total);
try testing.expectEqual(@as(u32, 1), body.available);
try testing.expect(!body.upstreams[1].enabled);
try testing.expect(!body.upstreams[1].available);
try testing.expectEqual(@as(u64, 2), body.upstreams[1].period.attempts);
}
/// Fills the accumulator and then overflows it, so `last_drop_minute` is
/// `minute` — the only way to set it, because the accumulator's fields are
/// private to its module and `snapshotStats` is the read surface.
fn accumulatorDroppingAt(
io: std.Io,
minute: i64,
names: *[history_mod.max_pending][8]u8,
) !*history_mod.Accumulator {
const accumulator = try testing.allocator.create(history_mod.Accumulator);
accumulator.* = .init;
for (names, 0..) |*name, i| {
const url = std.fmt.bufPrint(name, "u{d:0>6}", .{i}) catch unreachable;
accumulator.recordSuccess(io, url, minute);
}
// One more cell than capacity: the oldest minute goes, and every cell above
// holds `minute`.
accumulator.recordSuccess(io, "https://overflow.test", minute + 60);
return accumulator;
}
test "complete is false only while a dropped minute falls inside the window" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
var entries = [_]pool_mod.Entry{testEntry(url_a, true)};
var pool = testPool(&entries);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Two `now`s one minute apart, so the same drop is inside the first
// window and one minute before the second.
const inside_now = aligned_now;
const inside = stats.window(.@"1h", inside_now);
const after = stats.window(.@"1h", inside_now + 60);
try testing.expectEqual(inside.since + 60, after.since);
var names: [history_mod.max_pending][8]u8 = undefined;
const accumulator = try accumulatorDroppingAt(io, inside.since, &names);
defer testing.allocator.destroy(accumulator);
try testing.expectEqual(@as(?i64, inside.since), accumulator.snapshotStats(io).last_drop_minute);
const flagged = try collect(arena.allocator(), io, &pool, accumulator, &database, .@"1h", inside_now);
try testing.expect(!flagged.complete);
const recovered = try collect(arena.allocator(), io, &pool, accumulator, &database, .@"1h", inside_now + 60);
try testing.expect(recovered.complete);
// No accumulator at all is no known drop, not an incomplete window.
const unwired = try collect(arena.allocator(), io, &pool, null, &database, .@"1h", inside_now);
try testing.expect(unwired.complete);
}
test "a drop with no overflow leaves every window complete" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
var entries = [_]pool_mod.Entry{testEntry(url_a, true)};
var pool = testPool(&entries);
const accumulator = try testing.allocator.create(history_mod.Accumulator);
defer testing.allocator.destroy(accumulator);
accumulator.* = .init;
accumulator.recordFailure(io, url_a, aligned_now, "Timeout");
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const body = try collect(arena.allocator(), io, &pool, accumulator, &database, .@"1h", aligned_now);
try testing.expect(body.complete);
}
test "the route's period grammar and its 400 text are the ones /api/stats serves" {
// The picker scopes the whole page, so one bad spelling must mean the same
// thing on every route it drives.
try testing.expectEqual(stats.default_period, try stats.periodParam(""));
try testing.expectEqual(stats.Period.@"24h", try stats.periodParam(""));
try testing.expectEqual(stats.Period.@"7d", try stats.periodParam("period=7d"));
try testing.expectError(error.BadPeriod, stats.periodParam("period=12h"));
try testing.expectError(error.BadPeriod, stats.periodParam("period=1hhhhhhhhhh"));
try testing.expectEqualStrings("period must be one of 1h, 24h, 7d, 30d", bad_period_message);
}
test "every period the grammar accepts produces the window that period names" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
var entries = [_]pool_mod.Entry{testEntry(url_a, true)};
var pool = testPool(&entries);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
for (std.enums.values(stats.Period)) |period| {
const span = stats.window(period, aligned_now);
const body = try collect(arena.allocator(), io, &pool, null, &database, period, aligned_now);
try testing.expectEqualStrings(period.label(), body.period);
try testing.expectEqual(span.since, body.since);
try testing.expectEqual(span.until, body.until);
}
}
test "the body serializes exactly the ranged field set" {
const upstreams = [_]Upstream{ .{
.url = url_a,
.enabled = true,
.available = false,
.period = .{
.attempts = 8,
.successes = 6,
.failures = 2,
.success_rate = 0.75,
.last_failure_at = 1_700_000_000,
.last_failure_error = "ConnectFailed",
},
}, .{
.url = url_b,
.enabled = false,
.available = false,
.period = .{
.attempts = 0,
.successes = 0,
.failures = 0,
.success_rate = null,
.last_failure_at = null,
.last_failure_error = null,
},
} };
var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
defer allocating.deinit();
try std.json.Stringify.value(Body{
.period = "1h",
.since = 1_699_996_400,
.until = 1_700_000_000,
.available = 0,
.total = 2,
.complete = true,
.upstreams = &upstreams,
}, .{}, &allocating.writer);
try testing.expectEqualStrings(
\\{"period":"1h","since":1699996400,"until":1700000000,"available":0,"total":2,"complete":true,"upstreams":[{"url":"https://a.test/dns-query","enabled":true,"available":false,"period":{"attempts":8,"successes":6,"failures":2,"success_rate":0.75,"last_failure_at":1700000000,"last_failure_error":"ConnectFailed"}},{"url":"https://b.test/dns-query","enabled":false,"available":false,"period":{"attempts":0,"successes":0,"failures":0,"success_rate":null,"last_failure_at":null,"last_failure_error":null}}]}
, allocating.written());
// The lifetime fields m26 removed. They still exist in `health.State` and in
// `/metrics`; a client of this route must not find them here and start
// reading them as if they were scoped to the period.
for ([_][]const u8{
"consecutive_failures",
"total_successes",
"total_failures",
"last_error_age_s",
"\"last_error\"",
}) |gone| {
try testing.expect(!std.mem.containsAtLeast(u8, allocating.written(), 1, gone));
}
}
+1 -62
View File
@@ -31,7 +31,6 @@ const dns_handler = @import("../server/handler.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
const dot_server = @import("../server/dot_server.zig");
const events_mod = @import("../storage/events.zig");
const history_mod = @import("../upstream/history.zig");
const http_util = @import("http_util.zig");
const logging = @import("../platform/logging.zig");
const pool_mod = @import("../upstream/pool.zig");
@@ -136,9 +135,6 @@ pub const Sample = struct {
tracker: ?TrackerSample = null,
client_names: ?client_names.Resolver.Stats = null,
retention: ?retention_mod.Stats = null,
/// The upstream-history flush loop's counters (m26 ruling 7). Absent while
/// no accumulator is wired, like every other collaborator.
history: ?history_mod.Accumulator.Stats = null,
/// The diagnostics store's open episodes and its failed writes. Absent
/// while no store is wired, like every other collaborator — an operator
/// distinguishes "no series" from "zero episodes" through `/api/health`,
@@ -218,8 +214,6 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
if (state.retention) |retention| sample.retention = retention.snapshotStats();
if (state.history) |history| sample.history = history.snapshotStats(io);
if (state.events) |store| {
const counts = store.activeCounts(io);
sample.diagnostics = .{
@@ -382,36 +376,6 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
try counterGroup(w, "nxdns_retention_", "Query log retention counter", retention);
}
if (sample.history) |history| {
// Written out rather than reflected over `Accumulator.Stats`: three of
// its fields are counters, one is a gauge, and two — the drop watermark
// and the current flush state — are not exposition numbers at all.
try counter(
w,
"nxdns_upstream_history_flushes_total",
"Upstream history flush transactions that committed.",
history.flushes,
);
try counter(
w,
"nxdns_upstream_history_flush_failures_total",
"Upstream history flush attempts that failed; the rows are retried on the next pass.",
history.flush_failures,
);
try counter(
w,
"nxdns_upstream_history_rows_dropped_total",
"Upstream history minutes dropped because the accumulator was full.",
history.rows_dropped,
);
try gauge(
w,
"nxdns_upstream_history_pending",
"Upstream history minutes recorded but not yet flushed.",
history.pending,
);
}
if (sample.diagnostics) |diagnostics| {
try gauge(
w,
@@ -638,7 +602,7 @@ fn writeUpstreamLabels(
/// through this function too, so the guarantee cannot be one caller away.
/// `UpstreamSample.url` stays whole for the same reason it is safe to: nothing
/// but this function reads it, and the session-authenticated
/// `GET /api/upstream/health` reports the same pool with the same urls whole.
/// `GET /api/upstreams` reports the same urls whole.
///
/// **`redact` output is not safe to interpolate into a label value, and this
/// function is the reason it never has to be.** Do not delete the second layer
@@ -803,31 +767,6 @@ test "a full sample renders the whole exposition, byte for byte" {
));
}
test "the upstream-history family renders three counters and one gauge" {
const text = try renderToString(testing.allocator, .{
.history = .{
.flushes = 12,
.flush_failures = 2,
.rows_dropped = 5,
.pending = 3,
.last_drop_minute = 1_700_000_040,
.last_flush_failed = true,
},
});
defer testing.allocator.free(text);
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_history_flushes_total 12\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_history_flush_failures_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_history_rows_dropped_total 5\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_upstream_history_pending gauge\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_history_pending 3\n"));
// No accumulator is an absent family, not a family of zeros.
const bare = try renderToString(testing.allocator, .{});
defer testing.allocator.free(bare);
try testing.expect(!std.mem.containsAtLeast(u8, bare, 1, "nxdns_upstream_history_"));
}
test "the diagnostics family renders two gauges and one counter" {
const text = try renderToString(testing.allocator, .{
.diagnostics = .{ .active_warnings = 3, .active_errors = 1, .write_failures = 7 },
+289 -131
View File
@@ -65,8 +65,11 @@ paths:
get:
summary: Health rollup
description: |
Always 200; `status` is `degraded` when the disk is not ok, no
upstream is available, or the query-log writer failed. Always
Always 200. `status` is `degraded` when, and only when, one of the five
condition objects is in a degrading state: protection `unavailable`,
upstreams `unavailable`, query history `losing` or `failed`,
diagnostics `unavailable`, or disk `low` or `critical`. A paused
protection is an operator's own choice and does not degrade. Always
unauthenticated and never rate limited.
security: []
responses:
@@ -465,6 +468,99 @@ paths:
"503":
$ref: "#/components/responses/Unavailable"
/api/stats/types:
get:
summary: Query-type breakdown for a period
description: |
How many queries of each DNS type the period's window holds, over the
same UTC-aligned window `/api/stats` reports for. Rows carry the numeric
type only: the type-name table lives in the admin, and a second copy
here would drift out of agreement with it. `qtype` is nullable in the
query log, so the rows that carry no type group into a row of their own
rather than vanishing from a breakdown that claims to add up. Ordered by
count descending, then type ascending with the null row last. Types
absent from the window are absent from the list.
parameters:
- $ref: "#/components/parameters/Period"
responses:
"200":
description: The type breakdown.
content:
application/json:
schema:
$ref: "#/components/schemas/StatsTypes"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/Internal"
"503":
$ref: "#/components/responses/Unavailable"
/api/stats/routes:
get:
summary: How the period's queries were answered
description: |
A breakdown by answering route over the same window `/api/stats`
reports for. `source` is the answering resolver's identity — the
upstream url on `upstream` rows, the zone on `forward_zone` rows, null
on every other kind and on rows whose identity the log did not record.
It is not the blocklist a block came from. Ordered by count descending,
then route ascending, then source ascending with nulls last.
parameters:
- $ref: "#/components/parameters/Period"
responses:
"200":
description: The route breakdown.
content:
application/json:
schema:
$ref: "#/components/schemas/StatsRoutes"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/Internal"
"503":
$ref: "#/components/responses/Unavailable"
/api/stats/clients:
get:
summary: Per-client bucketed counts for a period
description: |
One zero-filled series per client, bucketed exactly like
`/api/stats/timeseries` so the two charts share an x-axis. The eight
clients with the most queries in the window are named, ranked by count
descending then address ascending; every other client sums into
`other`, which is always present and always holds one entry per bucket
in the window — including when `clients` is empty, when no client fell
outside the named eight, and when the window holds no queries at all.
parameters:
- $ref: "#/components/parameters/Period"
responses:
"200":
description: The per-client series.
content:
application/json:
schema:
$ref: "#/components/schemas/StatsClients"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/Internal"
"503":
$ref: "#/components/responses/Unavailable"
/api/lookup:
get:
summary: Explain a domain
@@ -498,35 +594,6 @@ paths:
"503":
$ref: "#/components/responses/Unavailable"
/api/upstream/health:
get:
summary: Upstream pool health for a period
description: |
Each upstream's live routing state beside its recorded outcomes over
the period's window, which is the same UTC-aligned window `/api/stats`
reports for that period. The outcome counts come from per-minute
history in the query log, not from process-lifetime counters, so they
scope to the period and survive a restart.
parameters:
- $ref: "#/components/parameters/Period"
responses:
"200":
description: Per-upstream state and the availability rollup.
content:
application/json:
schema:
$ref: "#/components/schemas/UpstreamHealth"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/Internal"
"503":
$ref: "#/components/responses/Unavailable"
/api/groups:
get:
summary: List groups
@@ -1822,45 +1889,100 @@ components:
Health:
type: object
required: [status, disk, upstreams, diagnostics, queries_dropped, writer_failed, refreshes_gated, snapshot_generation]
required: [status, protection, upstreams, query_history, diagnostics, disk]
properties:
status:
type: string
enum: [ok, degraded]
diagnostics:
type: object
required: [state, active_warnings, active_errors]
properties:
state:
type: string
enum: [recording, unavailable]
description: unavailable when the event store failed to open or its writes are failing; either state degrades health.
active_warnings: { type: integer }
active_errors: { type: integer }
disk:
type: object
required: [state, free_bytes, db_bytes, log_bytes, sample_failures]
properties:
state:
type: string
enum: [ok, warn, critical]
free_bytes: { type: integer }
db_bytes: { type: integer }
log_bytes: { type: integer }
sample_failures: { type: integer }
protection:
$ref: "#/components/schemas/HealthProtection"
upstreams:
type: object
required: [available, total]
properties:
available: { type: integer }
total: { type: integer }
queries_dropped: { type: integer }
writer_failed: { type: boolean }
refreshes_gated: { type: integer }
snapshot_generation:
$ref: "#/components/schemas/HealthUpstreams"
query_history:
$ref: "#/components/schemas/HealthQueryHistory"
diagnostics:
$ref: "#/components/schemas/HealthDiagnostics"
disk:
$ref: "#/components/schemas/HealthDisk"
HealthProtection:
type: object
required: [state, until]
properties:
state:
type: string
enum: [active, paused, unavailable]
description: >
unavailable when no filter snapshot exists for the query path to
evaluate against, which outranks any pause and is the only one of
the three that degrades health. An expired timed pause is active.
until:
type: integer
nullable: true
description: Null until the first filter snapshot is published.
description: >
The second filtering resumes at. Null for an indefinite pause and
for every state other than paused.
HealthUpstreams:
type: object
required: [state, available, total]
properties:
state:
type: string
enum: [ok, unavailable]
description: unavailable exactly when `available` is 0; that degrades health.
available: { type: integer }
total:
type: integer
description: Enabled upstreams, which is what the routing pool is built from.
HealthQueryHistory:
type: object
required: [state, dropped_total, last_drop_s]
properties:
state:
type: string
enum: [recording, losing, failed]
description: >
failed when the query-log writer never started; losing while the
disk gate is holding writes back and has already cost rows in the
episode open now. Both degrade health. Drops from an earlier
episode do not change the state - they are reported by the two
fields below.
dropped_total:
type: integer
description: Query rows lost since this process started, cumulative.
last_drop_s:
type: integer
nullable: true
description: >
The newest drop, unix seconds; null until one happens. Stamped by a
separate atomic from the count, so a non-zero `dropped_total` beside
a null here is a legal momentary answer.
HealthDiagnostics:
type: object
required: [state, active_warnings, active_errors]
properties:
state:
type: string
enum: [recording, unavailable]
description: unavailable when the event store failed to open or its writes are failing; either state degrades health.
active_warnings: { type: integer }
active_errors: { type: integer }
HealthDisk:
type: object
required: [state, free_bytes]
properties:
state:
type: string
enum: [ok, low, critical]
description: >
The disk monitor's own states; its `warn` is renamed `low` here,
because `warn` reads as a log level rather than as a quantity of
disk. Both `low` and `critical` degrade health.
free_bytes: { type: integer }
Version:
type: object
@@ -2119,6 +2241,9 @@ components:
- query_log.write
- query_log.maintenance
- query_log.recreated
# Legacy: nothing emits this any more (milestone 30 deleted the
# upstream-minute history subsystem), but stored rows survive and
# the list endpoint passes their code through.
- upstream_history.write
- upstream.exchange
- client_names.storage
@@ -2187,7 +2312,7 @@ components:
StatsTotals:
type: object
required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us, coverage]
required: [period, since, until, queries, blocked, clients, avg_response_time_us, coverage]
properties:
period:
type: string
@@ -2200,7 +2325,6 @@ components:
description: Window end, unix seconds, exclusive.
queries: { type: integer }
blocked: { type: integer }
cached: { type: integer }
clients:
type: integer
description: Distinct client addresses in the window.
@@ -2239,6 +2363,106 @@ components:
coverage:
$ref: "#/components/schemas/Coverage"
TypeCount:
type: object
required: [qtype, count]
properties:
qtype:
type: integer
nullable: true
description: |
The numeric DNS type. Null is the group of logged queries that
recorded no type, not an absent row.
count: { type: integer }
StatsTypes:
type: object
required: [period, since, until, types, coverage]
properties:
period:
type: string
enum: [1h, 24h, 7d, 30d]
since: { type: integer }
until: { type: integer }
types:
type: array
items:
$ref: "#/components/schemas/TypeCount"
coverage:
$ref: "#/components/schemas/Coverage"
RouteCount:
type: object
required: [route, source, count]
properties:
route:
$ref: "#/components/schemas/RouteKind"
source:
type: string
nullable: true
description: |
The answering upstream's url or the forward zone, and null on every
other route kind. Also null when an `upstream` or `forward_zone`
row recorded no identity.
count: { type: integer }
StatsRoutes:
type: object
required: [period, since, until, routes, coverage]
properties:
period:
type: string
enum: [1h, 24h, 7d, 30d]
since: { type: integer }
until: { type: integer }
routes:
type: array
items:
$ref: "#/components/schemas/RouteCount"
coverage:
$ref: "#/components/schemas/Coverage"
ClientSeries:
type: object
required: [client, buckets]
properties:
client:
type: string
description: The client address as the log recorded it, redaction included.
buckets:
type: array
description: |
One count per bucket in the window, zero-filled. Every series in a
response has this same length, `other` included.
items:
type: integer
StatsClients:
type: object
required: [period, since, until, bucket_seconds, clients, other, coverage]
properties:
period:
type: string
enum: [1h, 24h, 7d, 30d]
since: { type: integer }
until: { type: integer }
bucket_seconds: { type: integer }
clients:
type: array
items:
$ref: "#/components/schemas/ClientSeries"
other:
type: array
description: |
Every client outside the named eight, summed per bucket. Always
present, and always one entry per bucket in the window — including
when `clients` is empty, when no client fell outside the named
eight, and when the window holds no queries at all.
items:
type: integer
coverage:
$ref: "#/components/schemas/Coverage"
Lookup:
type: object
required: [domain, group_id, local_records, forward_zone, blocked, reason, matched, source_url, safe_search_rewrite]
@@ -2267,72 +2491,6 @@ components:
type: string
nullable: true
UpstreamPeriodStats:
type: object
required: [attempts, successes, failures, success_rate, last_failure_at, last_failure_error]
properties:
attempts:
type: integer
description: Exchanges recorded against this upstream inside the window.
successes: { type: integer }
failures: { type: integer }
success_rate:
type: number
nullable: true
description: >
`successes / attempts`, from 0 to 1. Null when `attempts` is 0: no
observations is not perfect reliability.
last_failure_at:
type: integer
nullable: true
description: >
The newest failure inside the window, unix seconds. Null when the
window holds no failure, even if the upstream failed before it.
last_failure_error:
type: string
nullable: true
description: The error name belonging to `last_failure_at`; null exactly when it is.
UpstreamHealth:
type: object
required: [period, since, until, available, total, complete, upstreams]
properties:
period:
type: string
enum: [1h, 24h, 7d, 30d]
since:
type: integer
description: Window start, unix seconds, inclusive.
until:
type: integer
description: Window end, unix seconds, exclusive.
available:
type: integer
description: How many upstreams the pool would route to right now.
total: { type: integer }
complete:
type: boolean
description: >
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.
upstreams:
type: array
description: The upstreams configured now; a deleted upstream's history is not returned.
items:
type: object
required: [url, enabled, available, period]
properties:
url: { type: string }
enabled:
type: boolean
description: Live configuration, not history.
available:
type: boolean
description: Live state, not history; false while the upstream is backing off.
period:
$ref: "#/components/schemas/UpstreamPeriodStats"
Group:
type: object
required: [id, name, safe_search]
+4 -3
View File
@@ -49,7 +49,6 @@ const queries = @import("handlers/queries.zig");
const rules = @import("handlers/rules.zig");
const settings = @import("handlers/settings.zig");
const stats = @import("handlers/stats.zig");
const upstream_health = @import("handlers/upstream_health.zig");
const upstreams = @import("handlers/upstreams.zig");
const version = @import("handlers/version.zig");
@@ -73,8 +72,10 @@ pub const table: []const router.RouteInfo = &.{
.{ .method = .GET, .pattern = "/api/queries/{id}", .auth = .session, .policy = .read, .handler = queries.detail },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
.{ .method = .GET, .pattern = "/api/stats/types", .auth = .session, .policy = .read, .handler = stats.types },
.{ .method = .GET, .pattern = "/api/stats/routes", .auth = .session, .policy = .read, .handler = stats.routes },
.{ .method = .GET, .pattern = "/api/stats/clients", .auth = .session, .policy = .read, .handler = stats.clients },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle },
// Diagnostics: the operational event log (milestone 27). The two purges are
// `runtime_action` — the event log is runtime state no configuration file
@@ -156,7 +157,7 @@ const std = @import("std");
const testing = std.testing;
test "the table carries every endpoint of the milestone" {
try testing.expectEqual(@as(usize, 61), table.len);
try testing.expectEqual(@as(usize, 63), table.len);
}
test "no two entries claim the same method and pattern" {
+77 -5
View File
@@ -40,7 +40,6 @@ const logger_mod = @import("../storage/logger.zig");
const manager_mod = @import("../filter/manager.zig");
const model = @import("../config/model.zig");
const pause_mod = @import("../server/pause.zig");
const history_mod = @import("../upstream/history.zig");
const pool_mod = @import("../upstream/pool.zig");
const query_sink = @import("../server/query_sink.zig");
const retention_mod = @import("../storage/retention.zig");
@@ -137,10 +136,6 @@ pub const WebState = struct {
client_names: ?*client_names.Resolver = null,
manager: ?*manager_mod.Manager = null,
pool: ?*pool_mod.Pool = null,
/// The upstream-outcome accumulator, for `metrics.collect` and the
/// `/api/health` rollup (m26 ruling 7). The ranged endpoint reads the
/// flushed rows through `querylog_db`, not through this.
history: ?*history_mod.Accumulator = null,
monitor: ?*disk_monitor.Monitor = null,
/// The local records and forward zones the DNS path reads. The
/// local-records and forward-zones handlers rebuild and swap them
@@ -184,6 +179,13 @@ pub const WebState = struct {
/// concurrent writes would misread each other's row counts.
config_lock: std.Io.Mutex = .init,
querylog_db: ?*db.Db = null,
/// Serializes the web layer's work on `querylog_db`, for the same reason
/// `config_lock` exists and one more: the read handlers wrap their several
/// statements in a transaction, and SQLite's serialized mode protects a
/// single call, not a transaction. Without this, two concurrent BEGINs on
/// the shared connection would fail and a third task's reads would land
/// inside someone else's snapshot.
querylog_lock: std.Io.Mutex = .init,
/// The diagnostics event store, which owns a third connection of its own
/// and serializes every access — read and write — through its mutex. Null
/// when `Store.init` failed, which `/api/health` reports as `unavailable`
@@ -219,6 +221,76 @@ pub const WebState = struct {
reload_fn: ?ReloadFn = null,
};
/// One response's hold on the query log: `querylog_lock` plus one deferred read
/// transaction, opened and closed together so no reader can hold one without
/// the other.
///
/// Every web-layer read of `querylog_db` goes through this. The transaction is
/// what makes an aggregate and the coverage watermark beside it describe one
/// database state, and the lock is what makes the transaction meaningful on a
/// connection several tasks share.
///
/// `commit` is fallible and must be called before the response is written: a
/// connection still inside a transaction refuses the next `BEGIN`, so a handler
/// that answered 200 over a failed commit would leave every later query-log
/// request failing for a reason nothing on the wire ever named.
///
/// **The lock is always released, even when the transaction could not be
/// ended.** `lockUncancelable` cannot be interrupted, so holding it against a
/// connection that will not leave its transaction would park every later
/// query-log task forever, with no status and no way out but a kill. Releasing
/// it turns the same fault into a 500 per request: bounded, visible, and
/// recoverable by a restart.
/// **The lock is released exactly once, on every path.** The usage shape below
/// runs `abort` after a failed `commit` — an `errdefer` cannot know the error
/// came from the commit itself — so `release` is the single owner of the
/// unlock and `held` is what makes the second call a no-op. Unlocking an
/// already-unlocked `std.Io.Mutex` is `unreachable`, and under contention it
/// would hand away a hold another task had just taken, so the bounded 500 this
/// type promises would instead be a crash or a corrupted mutex.
///
/// ```zig
/// var scope = try QuerylogRead.open(state, io, database);
/// errdefer scope.abort();
/// ... // reads only
/// try scope.commit();
/// ```
pub const QuerylogRead = struct {
state: *WebState,
io: std.Io,
tx: db.ReadTx,
held: bool,
pub fn open(state: *WebState, io: std.Io, database: *db.Db) db.Error!QuerylogRead {
state.querylog_lock.lockUncancelable(io);
errdefer state.querylog_lock.unlock(io);
return .{
.state = state,
.io = io,
.tx = try db.ReadTx.begin(database),
.held = true,
};
}
pub fn commit(self: *QuerylogRead) db.Error!void {
defer self.release();
return self.tx.commit();
}
/// Safe in `errdefer`, and safe after `commit` however that ended: both the
/// rollback and the release are idempotent.
pub fn abort(self: *QuerylogRead) void {
self.tx.rollback();
self.release();
}
fn release(self: *QuerylogRead) void {
if (!self.held) return;
self.held = false;
self.state.querylog_lock.unlock(self.io);
}
};
/// Ruling 17. Authentication is enabled iff a password hash is set — the live
/// one, so a password set through the API locks the routes without a restart.
/// With it set but no session store wired, every session route is refused: the
+438 -11
View File
@@ -32,6 +32,7 @@ const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig");
const dns_handler = @import("../server/handler.zig");
const events_mod = @import("../storage/events.zig");
const events_repo = @import("../storage/repositories/events_repo.zig");
const fetcher = @import("../filter/fetcher.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const header = @import("../dns/header.zig");
@@ -72,7 +73,6 @@ const handlers_pause = @import("handlers/pause.zig");
const handlers_queries = @import("handlers/queries.zig");
const handlers_settings = @import("handlers/settings.zig");
const handlers_stats = @import("handlers/stats.zig");
const handlers_upstream_health = @import("handlers/upstream_health.zig");
const handlers_version = @import("handlers/version.zig");
const testing = std.testing;
@@ -282,6 +282,13 @@ const EnvOptions = struct {
/// `logging.query_log = false` operator runs. Every query-log route then
/// answers 503 rather than an empty page, which would be a lie.
querylog: bool = true,
/// Seeds a handful of rows inside the *live* period window, on top of the
/// fixed 2023 seed. The stats windows are cut from the real clock, so an
/// aggregation over a fixed seed is always an empty window — and an empty
/// array witnesses no field at all. Only the tests that need populated
/// aggregations ask for it: the rows are newer than every fixed row, so
/// they would otherwise move the query-log page out from under its golden.
recent_traffic: bool = false,
};
/// Heap-allocated because `state` and the listener hold pointers into it.
@@ -334,13 +341,14 @@ const Env = struct {
errdefer self.querylog_db.close();
try self.querylog_db.exec(querylog_schema.ddl);
try seedQueryLog(&self.querylog_db);
if (options.recent_traffic) try seedRecentTraffic(&self.querylog_db, std.Io.Clock.real.now(ioh).toSeconds());
self.events_db = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer self.events_db.close();
try db.applyPragmas(&self.events_db, .{});
_ = try migrations.migrate(&self.events_db);
self.events_store = try events_mod.Store.init(ioh, &self.events_db, seeded_now);
seedEvents(ioh, &self.events_store);
try seedEvents(ioh, &self.events_store, &self.events_db);
// Real fetcher wiring; nothing in this suite downloads (the one
// refreshAll in the contract walk runs with zero source rows).
@@ -381,7 +389,8 @@ const Env = struct {
self.pool_entries = .{.{
.endpoint = transport.Endpoint.parse("https://dns.example/dns-query") catch unreachable,
// Never exchanged with: the pool feeds /api/upstream/health only.
// Never exchanged with: the pool feeds `/metrics` and the
// `/api/health` upstream condition only.
.client = .{ .ptr = undefined, .exchangeFn = undefined },
.priority = 1,
.enabled = true,
@@ -588,6 +597,66 @@ fn seedQueryLog(database: *db.Db) !void {
}});
}
/// The matrix the three period aggregations are read against: three clients,
/// three query types including a row with none, five route kinds, two named
/// upstreams and one upstream row whose resolver the log did not record.
///
/// `now` is the real clock, so these rows land in the live window of every
/// period. Only their timestamps come from it; the counts are fixed, and the
/// contract samples canonicalize every number to zero anyway.
const recent_clients = 3;
fn seedRecentTraffic(database: *db.Db, now: i64) !void {
var writer = try queries_repo.BatchWriter.init(database);
defer writer.deinit();
const Shape = struct {
client: []const u8,
qtype: ?u16,
kind: provenance.RouteKind,
source: ?[]const u8,
};
const shapes = [_]Shape{
.{ .client = "192.0.2.30", .qtype = 1, .kind = .upstream, .source = "https://dns.example/dns-query" },
.{ .client = "192.0.2.30", .qtype = 1, .kind = .upstream, .source = "https://dns.example/dns-query" },
.{ .client = "192.0.2.30", .qtype = 28, .kind = .upstream, .source = "https://dns2.example/dns-query" },
.{ .client = "192.0.2.30", .qtype = 1, .kind = .upstream, .source = null },
.{ .client = "192.0.2.31", .qtype = 28, .kind = .blocked, .source = null },
.{ .client = "192.0.2.31", .qtype = 1, .kind = .cache, .source = null },
.{ .client = "192.0.2.31", .qtype = null, .kind = .local, .source = null },
.{ .client = "192.0.2.32", .qtype = 1, .kind = .forward_zone, .source = "lan" },
.{ .client = "192.0.2.32", .qtype = 1, .kind = .rejected, .source = null },
};
for (shapes, 0..) |shape, index| {
// Inside the narrowest bucket of the narrowest period, so every period
// sees the whole matrix however close to a boundary the clock is.
try writer.writeBatch(&.{.{
.timestamp = now - @as(i64, @intCast(index)) - 1,
.domain = "recent.example",
.client_ip = shape.client,
.qtype = shape.qtype,
.qclass = 1,
.rcode = 0,
.blocked = shape.kind == .blocked,
.response_time_us = 1500,
.cache_hit = shape.kind == .cache,
.upstream = if (shape.kind == .upstream) shape.source else null,
.group_id = 1,
.group_name = "default",
.policy_action = if (shape.kind == .blocked) .block else .allow,
.policy_reason = if (shape.kind == .blocked) .blocklist_domain else .no_match,
.matched = null,
.source_id = null,
.source_name = null,
.cname_target = null,
.safe_search_target = null,
.route_kind = shape.kind,
.forward_zone = if (shape.kind == .forward_zone) shape.source else null,
}});
}
}
/// A fixed instant, like every other seeded timestamp here: the contract
/// samples are byte-compared, so nothing the walk writes may come from a clock.
const seeded_now: i64 = 1_787_118_000;
@@ -595,12 +664,17 @@ const seeded_now: i64 = 1_787_118_000;
/// One active episode and one resolved one, so `/api/diagnostics` answers with
/// both states and the committed contract sample describes a real page rather
/// than an empty one.
fn seedEvents(io: std.Io, store: *events_mod.Store) void {
fn seedEvents(io: std.Io, store: *events_mod.Store, database: *db.Db) !void {
store.report(io, seeded_now, .blocklist_refresh, "https://lists.example/ads.txt", "StevenBlack", .warning, "download failed: ConnectionTimedOut");
store.report(io, seeded_now + 300, .blocklist_refresh, "https://lists.example/ads.txt", "StevenBlack", .warning, "download failed: ConnectionTimedOut");
store.report(io, seeded_now + 60, .upstream_history_write, "history", "history", .warning, "Busy");
store.resolve(io, seeded_now + 120, .upstream_history_write, "history");
// A legacy code no producer emits any more. Rows written by an m29 process
// survive, and the read path has to keep passing their code through — this
// is the resolved episode that proves it. Written through the repository
// because the emitter enum no longer has the code at all.
const legacy = events_mod.legacy_wire_codes[0];
_ = try events_repo.insertActive(database, seeded_now + 60, legacy, "history", "history", "warning", "Busy");
_ = try events_repo.resolveActiveByCode(database, seeded_now + 120, legacy);
}
// ---------------------------------------------------------------------------
@@ -750,7 +824,9 @@ const contract = [_]Contract{
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
.{ .method = .GET, .pattern = "/api/stats/types", .auth = .session, .policy = .read, .target = "/api/stats/types?period=1h", .status = 200, .check = jsonShape(handlers_stats.TypesBody) },
.{ .method = .GET, .pattern = "/api/stats/routes", .auth = .session, .policy = .read, .target = "/api/stats/routes?period=1h", .status = 200, .check = jsonShape(handlers_stats.RoutesBody) },
.{ .method = .GET, .pattern = "/api/stats/clients", .auth = .session, .policy = .read, .target = "/api/stats/clients?period=1h", .status = 200, .check = jsonShape(handlers_stats.ClientsBody) },
// Diagnostics. The seeded store holds one active episode (id 1) and one
// resolved one, so both the page and the detail answer with real rows.
@@ -1977,7 +2053,16 @@ fn detailUnavailable(io: std.Io, env: *Env) anyerror!void {
defer conn.close(io);
var body_buf: [8 * 1024]u8 = undefined;
for ([_][]const u8{ "/api/queries/1", "/api/queries?limit=1", "/api/stats", "/api/stats/timeseries" }) |target| {
const targets = [_][]const u8{
"/api/queries/1",
"/api/queries?limit=1",
"/api/stats",
"/api/stats/timeseries",
"/api/stats/types",
"/api/stats/routes",
"/api/stats/clients",
};
for (targets) |target| {
try conn.request("GET", target, null, null);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 503), response.status);
@@ -2064,6 +2149,260 @@ fn coverageWalk(io: std.Io, env: *Env) anyerror!void {
try testing.expectEqual(totals.coverage.complete, series.coverage.complete);
}
fn getJson(
comptime T: type,
arena: Allocator,
conn: *Conn,
target: []const u8,
body_buf: []u8,
) !T {
try conn.request("GET", target, null, null);
const response = try conn.receive(body_buf);
if (response.status != 200) {
std.debug.print("{s}: status {d}: {s}\n", .{ target, response.status, response.body });
return error.TestUnexpectedResult;
}
return std.json.parseFromSliceLeaky(T, arena, response.body, .{ .ignore_unknown_fields = false });
}
fn emptyAggregations(io: std.Io, env: *Env) anyerror!void {
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [256 * 1024]u8 = undefined;
// This environment's only rows are the fixed 2023 seed, so every live
// window is empty. The empty bodies are exact, not merely parseable.
const types_body = try getJson(handlers_stats.TypesBody, arena, &conn, "/api/stats/types?period=1h", &body_buf);
try testing.expectEqualStrings("1h", types_body.period);
try testing.expectEqual(@as(usize, 0), types_body.types.len);
const routes_body = try getJson(handlers_stats.RoutesBody, arena, &conn, "/api/stats/routes?period=1h", &body_buf);
try testing.expectEqual(@as(usize, 0), routes_body.routes.len);
// `other` is present and bucket-count sized even here: a chart must never
// have to invent the residual series.
const clients = try getJson(handlers_stats.ClientsBody, arena, &conn, "/api/stats/clients?period=1h", &body_buf);
try testing.expectEqual(@as(usize, 0), clients.clients.len);
try testing.expectEqual(@as(u32, 60), clients.bucket_seconds);
try testing.expectEqual(@as(usize, 60), clients.other.len);
for (clients.other) |count| try testing.expectEqual(@as(u64, 0), count);
// A window nobody covers is still reported as such, not as a quiet hour.
try testing.expectEqual(seeded_available_since, types_body.coverage.available_since);
try testing.expect(types_body.coverage.complete);
for ([_][]const u8{ "/api/stats/types", "/api/stats/routes", "/api/stats/clients" }) |path| {
var target_buf: [64]u8 = undefined;
const target = try std.fmt.bufPrint(&target_buf, "{s}?period=12h", .{path});
try conn.request("GET", target, null, null);
const bad = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 400), bad.status);
try testing.expect(std.mem.containsAtLeast(u8, bad.body, 1, "period must be one of"));
}
}
test "W10 milestone 30: an empty window answers exact empty aggregations, and a bad period is a 400" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, emptyAggregations, .{ env.io(), env });
}
fn populatedAggregations(io: std.Io, env: *Env) anyerror!void {
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [256 * 1024]u8 = undefined;
const totals = try getJson(handlers_stats.TotalsBody, arena, &conn, "/api/stats?period=1h", &body_buf);
const series = try getJson(handlers_stats.TimeseriesBody, arena, &conn, "/api/stats/timeseries?period=1h", &body_buf);
const types_body = try getJson(handlers_stats.TypesBody, arena, &conn, "/api/stats/types?period=1h", &body_buf);
const routes_body = try getJson(handlers_stats.RoutesBody, arena, &conn, "/api/stats/routes?period=1h", &body_buf);
const clients = try getJson(handlers_stats.ClientsBody, arena, &conn, "/api/stats/clients?period=1h", &body_buf);
// Nothing writes to this box between the five requests, so the window is
// one state and conservation is a real assertion rather than a race.
try testing.expectEqual(totals.since, series.since);
try testing.expectEqual(totals.since, types_body.since);
try testing.expectEqual(totals.since, routes_body.since);
try testing.expectEqual(totals.since, clients.since);
try testing.expect(totals.queries > 0);
var typed: u64 = 0;
var null_qtype_rows: usize = 0;
for (types_body.types) |row| {
typed += row.count;
if (row.qtype == null) null_qtype_rows += 1;
}
try testing.expectEqual(totals.queries, typed);
// The seeded matrix holds one typeless row, and it must be its own group.
try testing.expectEqual(@as(usize, 1), null_qtype_rows);
var routed: u64 = 0;
var null_source_upstreams: usize = 0;
var named_upstreams: usize = 0;
for (routes_body.routes) |row| {
routed += row.count;
if (row.route != .upstream) continue;
if (row.source == null) null_source_upstreams += 1 else named_upstreams += 1;
}
try testing.expectEqual(totals.queries, routed);
try testing.expectEqual(@as(usize, 1), null_source_upstreams);
try testing.expectEqual(@as(usize, 2), named_upstreams);
try testing.expectEqual(@as(usize, recent_clients), clients.clients.len);
try testing.expectEqual(series.buckets.len, clients.other.len);
for (clients.clients) |entry| try testing.expectEqual(series.buckets.len, entry.buckets.len);
// Per bucket, not just over the window: a series off by one bucket would
// still sum correctly in total.
for (series.buckets, 0..) |bucket, at| {
var summed: u64 = clients.other[at];
for (clients.clients) |entry| summed += entry.buckets[at];
try testing.expectEqual(bucket.queries, summed);
}
}
test "W10 milestone 30: the three breakdowns conserve the totals over one window" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .recent_traffic = true });
defer env.destroy();
try bounded(env.io(), default_budget, populatedAggregations, .{ env.io(), env });
}
/// One connection walking every query-log endpoint several times over.
fn hammerQuerylog(io: std.Io, env: *Env) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [256 * 1024]u8 = undefined;
const targets = [_][]const u8{
"/api/stats?period=1h",
"/api/stats/timeseries?period=1h",
"/api/stats/types?period=1h",
"/api/stats/routes?period=1h",
"/api/stats/clients?period=1h",
"/api/queries?limit=5",
"/api/queries/27",
};
for (0..3) |_| {
for (targets) |target| {
try conn.request("GET", target, null, null);
const response = try conn.receive(&body_buf);
if (response.status != 200) {
std.debug.print("{s}: status {d}: {s}\n", .{ target, response.status, response.body });
return error.TestUnexpectedResult;
}
}
}
}
fn concurrentQuerylogReads(io: std.Io, env: *Env) anyerror!void {
// Six tasks on six connections against the one shared query-log
// connection. Without `querylog_lock` this is exactly the shape that makes
// a second BEGIN fail and a foreign read land inside someone else's
// transaction; every response here must still be a 200.
var futures: [6]std.Io.Future(anyerror!void) = undefined;
for (&futures) |*future| future.* = try io.concurrent(hammerQuerylog, .{ io, env });
var failure: ?anyerror = null;
for (&futures) |*future| future.await(io) catch |err| {
failure = err;
};
if (failure) |err| return err;
}
fn failedCommitIsBounded(io: std.Io, env: *Env) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [256 * 1024]u8 = undefined;
// A read that cannot end its transaction. The three things that must hold
// are all observable from here: the client is told (500, not a 200 over a
// state nobody can name), the process survives (the lock is released
// exactly once — releasing twice is `unreachable` in `std.Io.Mutex`), and
// the connection recovers (the rollback attempt worked, so the next
// `BEGIN` is not refused).
db.read_tx_faults.failNextCommit();
try conn.request("GET", "/api/stats/types?period=1h", null, null);
const failed = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 500), failed.status);
try testing.expect(std.mem.containsAtLeast(u8, failed.body, 1, "internal error"));
// Same connection, same shared query-log handle: a request after the fault
// is an ordinary 200. This is the assertion the double-unlock bug failed —
// it panicked here instead of answering.
try conn.request("GET", "/api/stats/types?period=1h", null, null);
const recovered = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), recovered.status);
// And every other query-log route still works on that connection.
for ([_][]const u8{
"/api/stats?period=1h",
"/api/stats/timeseries?period=1h",
"/api/stats/routes?period=1h",
"/api/stats/clients?period=1h",
"/api/queries?limit=5",
"/api/queries/27",
}) |target| {
try conn.request("GET", target, null, null);
const response = try conn.receive(&body_buf);
if (response.status != 200) {
std.debug.print("{s} after the fault: status {d}\n", .{ target, response.status });
return error.TestUnexpectedResult;
}
}
}
test "W10 milestone 30: a read that cannot commit answers 500 and leaves the connection usable" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .recent_traffic = true });
defer env.destroy();
// The teardown fault is reported at `err`, which the test runner counts as
// a failure; this test causes it deliberately and asserts the count.
db.read_tx_faults.beginCapture();
defer _ = db.read_tx_faults.endCapture();
try bounded(env.io(), default_budget, failedCommitIsBounded, .{ env.io(), env });
// Exactly the one COMMIT fault: the ROLLBACK behind it succeeded, and no
// later request tripped a fault of its own.
try testing.expectEqual(@as(usize, 1), db.read_tx_faults.endCapture());
}
test "W10 milestone 30: concurrent query-log reads all answer 200 on the shared connection" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .recent_traffic = true });
defer env.destroy();
try bounded(env.io(), default_budget, concurrentQuerylogReads, .{ env.io(), env });
}
test "W10 milestone 28: every window-bounded endpoint reports its own coverage" {
if (!build_options.integration) return error.SkipZigTest;
@@ -2876,12 +3215,33 @@ fn documentedType(comptime T: type) ?[]const u8 {
.bool => "boolean",
// A closed enum is a string on the wire, documented as its own schema.
.@"enum" => null,
.pointer => "string",
// `[]const u8` is a string; every other slice is a JSON array, whose
// element type `elementType` below holds the `items:` block to.
.pointer => |ptr| if (ptr.child == u8) "string" else "array",
.@"struct" => null,
else => @compileError("no documented type for " ++ @typeName(Payload)),
};
}
/// The element type of a field that serializes as a JSON array, or null when
/// the field is not one. `[]const u8` is a string, not an array of integers.
fn elementType(comptime T: type) ?type {
const Payload = switch (@typeInfo(T)) {
.optional => |o| o.child,
else => T,
};
return switch (@typeInfo(Payload)) {
.pointer => |ptr| if (ptr.child == u8) null else ptr.child,
else => null,
};
}
/// The `items:` sub-block of an array property.
fn yamlItems(property: []const u8) ?[]const u8 {
const at = std.mem.indexOf(u8, property, "items:") orelse return null;
return property[at..];
}
fn isOptional(comptime T: type) bool {
return @typeInfo(T) == .optional;
}
@@ -2933,6 +3293,30 @@ fn expectSchemaMatches(gpa: Allocator, comptime T: type, schema_name: []const u8
std.debug.print("{s}.{s}: not documented as {s}\n", .{ schema_name, field.name, wanted });
return error.TestUnexpectedResult;
}
// An array is only as documented as its elements are: without this
// an array of one object would match an array of another.
if (comptime elementType(field.type)) |Element| {
const items = yamlItems(property) orelse {
std.debug.print("{s}.{s}: array with no items\n", .{ schema_name, field.name });
return error.TestUnexpectedResult;
};
switch (@typeInfo(Element)) {
.int => if (!std.mem.containsAtLeast(u8, items, 1, "type: integer")) {
std.debug.print("{s}.{s}: items not documented as integer\n", .{ schema_name, field.name });
return error.TestUnexpectedResult;
},
else => {
const target = refTarget(items) orelse {
std.debug.print("{s}.{s}: items are not a $ref\n", .{ schema_name, field.name });
return error.TestUnexpectedResult;
};
switch (@typeInfo(Element)) {
.@"enum" => try expectEnumMatches(gpa, Element, target),
else => try expectSchemaMatches(gpa, Element, target),
}
},
}
}
} else {
const target = refTarget(property) orelse {
std.debug.print("{s}.{s}: not a $ref\n", .{ schema_name, field.name });
@@ -2981,6 +3365,25 @@ fn expectEnumMatches(gpa: Allocator, comptime T: type, schema_name: []const u8)
}
}
test "drift guard c: the health rollup matches the five objects it documents" {
const gpa = testing.allocator;
// Recurses through the five `$ref`s, so a condition object that gains,
// loses or retypes a field fails here — which is the whole contract: no
// condition may degrade the rollup without appearing in the response.
try expectSchemaMatches(gpa, handlers_health.Body, "Health");
}
test "drift guard c: the stats schemas match the structs that serialize them" {
// Guard b counts operations and guard a matches paths, so neither noticed
// that `cached` outlived the field it documented. This one would have.
const gpa = testing.allocator;
try expectSchemaMatches(gpa, handlers_stats.TotalsBody, "StatsTotals");
try expectSchemaMatches(gpa, handlers_stats.TimeseriesBody, "StatsTimeseries");
try expectSchemaMatches(gpa, handlers_stats.TypesBody, "StatsTypes");
try expectSchemaMatches(gpa, handlers_stats.RoutesBody, "StatsRoutes");
try expectSchemaMatches(gpa, handlers_stats.ClientsBody, "StatsClients");
}
test "drift guard c: the query-log schemas match the structs that serialize them" {
const gpa = testing.allocator;
try expectSchemaMatches(gpa, queries_repo.QueryRow, "QueryRow");
@@ -3134,7 +3537,6 @@ const contract_sample_walk = [_]ContractSample{
.{ .name = "list_upstreams", .ts_type = "{ upstreams: Upstream[] }", .method = "GET", .target = "/api/upstreams", .status = 200 },
.{ .name = "create_upstream", .ts_type = "UpstreamEcho", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 },
.{ .name = "update_upstream", .ts_type = "UpstreamEcho", .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
.{ .name = "get_upstream_health", .ts_type = "UpstreamHealth", .method = "GET", .target = "/api/upstream/health", .status = 200 },
// Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so
// the page carries both the null-bearing and the populated row shape.
@@ -3163,6 +3565,15 @@ const contract_sample_walk = [_]ContractSample{
.{ .name = "error_not_found", .ts_type = "ErrorEnvelope", .method = "GET", .target = "/api/nope", .status = 404 },
};
/// The three period aggregations, captured against an environment with live
/// traffic in it: over the fixed 2023 seed every one of them would answer with
/// an empty array, which describes no field at all.
const stats_sample_walk = [_]ContractSample{
.{ .name = "get_stats_types", .ts_type = "StatsTypes", .method = "GET", .target = "/api/stats/types?period=1h", .status = 200 },
.{ .name = "get_stats_routes", .ts_type = "StatsRoutes", .method = "GET", .target = "/api/stats/routes?period=1h", .status = 200 },
.{ .name = "get_stats_clients", .ts_type = "StatsClients", .method = "GET", .target = "/api/stats/clients?period=1h", .status = 200 },
};
/// A session-authenticated environment answers this without a cookie.
const unauthorized_sample: ContractSample = .{
.name = "error_unauthorized",
@@ -3314,7 +3725,9 @@ const regen_command =
/// exactly the import list the generated file needs.
fn writeSampleImports(arena: Allocator, w: *std.Io.Writer) !void {
var names: std.ArrayList([]const u8) = .empty;
for (contract_sample_walk ++ [_]ContractSample{ unauthorized_sample, rate_limited_sample }) |sample| {
for (contract_sample_walk ++ stats_sample_walk ++
[_]ContractSample{ unauthorized_sample, rate_limited_sample }) |sample|
{
var index: usize = 0;
while (index < sample.ts_type.len) {
if (!std.ascii.isUpper(sample.ts_type[index])) {
@@ -3397,6 +3810,15 @@ fn sampleWalk(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
for (contract_sample_walk) |sample| try captureSample(env.gpa, &conn, out, sample, &body_buf);
}
fn statsSampleWalk(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [128 * 1024]u8 = undefined;
for (stats_sample_walk) |sample| try captureSample(env.gpa, &conn, out, sample, &body_buf);
}
fn sampleUnauthorized(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, env.addr);
@@ -3437,6 +3859,11 @@ test "W10 milestone 17: the committed contract samples still describe live respo
defer env.destroy();
try bounded(env.io(), default_budget, sampleWalk, .{ env.io(), env, &rendered.writer });
}
{
var env = try Env.create(gpa, .{ .recent_traffic = true });
defer env.destroy();
try bounded(env.io(), default_budget, statsSampleWalk, .{ env.io(), env, &rendered.writer });
}
{
var hash_buf: [256]u8 = undefined;
const hash = try hashTestPassword(gpa, &hash_buf);