milestone 27: diagnostics — operational failures land in one curated log, resolved history purgeable
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# Milestone 27: diagnostics vertical slice
|
||||
|
||||
Step 1 of the UI redesign (`specs/ui-redesign.md`). A curated log of operational failure episodes: one `operational_events` table in `config.db`, a serialized event store, fifteen typed event codes emitted at existing failure sites, `GET /api/diagnostics`, and a Diagnostics page in the admin SPA. The rest of the UI stays intact; the full navigation restructure is later steps.
|
||||
|
||||
Design authority: `specs/ui-redesign.md` §Diagnostics, as amended by the Fable review rulings recorded in that file. No `api.storage` code. No periodic probe. No new index beyond the two specced.
|
||||
|
||||
## Sessions
|
||||
|
||||
S1 (store + API) → S2 (emitters) and S3 (SPA) in parallel → orchestrator integration (contract samples regen, final wiring check).
|
||||
|
||||
---
|
||||
|
||||
## Session S1: event store, schema, API, health, metrics
|
||||
|
||||
### S1.1 Schema — `src/storage/config_schema.zig`
|
||||
|
||||
Append to `ddl_v1` (pre-v0.1: edit the baseline, no migration step; update PLAN §11.2 to match):
|
||||
|
||||
```sql
|
||||
CREATE TABLE operational_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
subject_key TEXT NOT NULL,
|
||||
subject_label TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
occurrences INTEGER NOT NULL CHECK (occurrences > 0),
|
||||
resolved_at INTEGER,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
CHECK (resolved_at IS NULL OR resolved_at >= first_seen)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_operational_events_active
|
||||
ON operational_events(code, subject_key) WHERE resolved_at IS NULL;
|
||||
CREATE INDEX idx_operational_events_last_seen
|
||||
ON operational_events(last_seen DESC);
|
||||
```
|
||||
|
||||
`operational_events` is runtime state, NOT configuration. It stays out of `table_names` and `delete_order` (`config_schema.zig:112-129`). Tests that must be updated deliberately, not silently: `migrations.zig:365` asserts `count(tables) == delete_order.len + 1` — becomes `+ 2`, and its comment names `operational_events` as the second exclusion alongside `schema_version`. `src/config/reconcile.zig:268` (`comptime assert(delete_order.len == 10)`) is untouched — the list does not change. Add a test asserting export/import round-trips leave `operational_events` rows intact.
|
||||
|
||||
Editing `ddl_v1` changes nothing for existing installs pre-0.1 EXCEPT the silent-divergence hazard already on record: the Pi's `config.db` is stamped version 1 and will not get the new table. This milestone accepts that with a bridge placed at the END of `migrations.migrate`, after the version stamp: execute the three `operational_events` statements with `IF NOT EXISTS` added. It must NOT live in `cli.openConfigDb` — `openConfigDb` runs before migration everywhere (`app.zig:333`, `cli.zig:484`, `cli.zig:516`), and creating the table there would make a fresh database's non-`IF NOT EXISTS` `ddl_v1` `CREATE TABLE` fail. The bridge is explicit, commented as removable when the 0.1 adoption gate lands, and touches no other table. Test: a version-1 database created WITHOUT the table gains exactly it after `migrate`, and a fresh database migrates cleanly (no double-create).
|
||||
|
||||
### S1.2 Event codes — `src/storage/events.zig` (new)
|
||||
|
||||
```zig
|
||||
pub const Code = enum {
|
||||
disk_space, disk_probe,
|
||||
blocklist_refresh, blocklist_snapshot, blocklist_storage,
|
||||
certificate_reload,
|
||||
query_log_write, query_log_maintenance, query_log_recreated,
|
||||
upstream_history_write, upstream_exchange,
|
||||
client_names_storage, clients_storage,
|
||||
listener_start, configuration_load,
|
||||
};
|
||||
```
|
||||
|
||||
Fifteen enum members — that count is the single cardinality every exhaustive test, route fixture and frontend copy union uses. The enum is the truth; the wire form is the dotted string (`disk_space` → `"disk.space"`, `blocklist_refresh` → `"blocklist.refresh"`, etc.) via `pub fn wire(code: Code) []const u8` — an exhaustive switch, tested against every member. `component(code)` returns the prefix before the dot, also exhaustive.
|
||||
|
||||
Severity is fixed per emit call, not per code (a disk transition to `warn` is warning, to `critical` is error).
|
||||
|
||||
### S1.3 Event store — `src/storage/events.zig`
|
||||
|
||||
```zig
|
||||
pub const Store = struct {
|
||||
pub const max_detail_len = 512;
|
||||
pub const max_subject_key_len = 256; // longer keys become sha256: digests — see the identity rule below
|
||||
pub const max_subject_label_len = 128;
|
||||
pub const resolved_retention_s: i64 = 90 * 86_400;
|
||||
pub const max_resolved_rows: i64 = 5_000;
|
||||
|
||||
mutex: std.Io.Mutex,
|
||||
database: *db.Db, // dedicated connection; ALL access goes through the Store's mutex
|
||||
write_failed: std.atomic.Value(bool),
|
||||
write_failures: std.atomic.Value(u64), // feeds the metrics counter
|
||||
active: ActiveSet, // in-memory mirror of active (code, subject_key) rows
|
||||
untracked_active_count: u32, // active rows NOT in the mirror (overflow); loaded at init
|
||||
|
||||
pub fn init(io, database, now_s) db.Error!Store // loads ActiveSet + untracked count, prunes; failure = no store
|
||||
pub const max_kept_keys = 64; // resolveExcept bound; over it the call is refused, counted and latched — truncating the kept list would close episodes that are still true
|
||||
pub fn report(self, io, now_s, code, subject_key, subject_label, severity, detail) void
|
||||
pub fn resolve(self, io, now_s, code, subject_key) void
|
||||
pub fn reportResolved(self, io, now_s, code, subject_key, subject_label, severity, detail) void
|
||||
pub fn resolveExcept(self, io, now_s, code, kept_keys: []const []const u8) void
|
||||
pub fn prune(self, io, now_s) void
|
||||
pub fn writeFailed(self) bool
|
||||
pub fn activeCounts(self, io) struct { warnings: u32, errors: u32 }
|
||||
pub fn selectEvents(self, io, arena, filter) db.Error!EventsPage
|
||||
pub fn selectOne(self, io, arena, id: i64) db.Error!?Event
|
||||
};
|
||||
```
|
||||
|
||||
Contract:
|
||||
|
||||
- **Time is a parameter, not a stored seam.** Every mutating method takes `now_s: i64` from the caller, matching how `history.zig:132` receives `wall_s` and `logger.zig:72` receives entry timestamps. Production callers compute it from `Clock.real`; tests pass literals. No function pointer, no clock inside the store.
|
||||
- **`resolve` is hot-path safe.** It checks `active` under the mutex and, when the key is absent AND `untracked_active_count == 0`, returns without any SQLite statement. `pool.recordSuccess` calls it on every successful exchange; steady state must cost a mutex acquire and a lookup. The no-SQL guarantee is tested through a debug-only statement counter on the Store (incremented before every repo call) — NOT via `sqlite3_total_changes`, which a `SELECT` probe would not move.
|
||||
- **Overflow is exact, not heuristic.** `ActiveSet` capacity is 256. The no-SQL fast path belongs to `resolve` ONLY: mirror miss + `untracked_active_count == 0` → return, no SQL (that is the steady-state success). `report` on a mirror miss always writes — when the count is zero it inserts directly (a new episode in normal state, no probe needed); when the count is nonzero it probes and upserts (touch the untracked active row if one exists — a second insert would collide with the partial unique index — else insert), incrementing `untracked_active_count` only when a new row was inserted AND the mirror is full. A successful slow-path `resolve` decrements the count. `init` loads the count as `active rows - mirrored rows`. `events_repo.resolveExcept` performs the bulk resolve and a count of TOTAL remaining active rows in ONE transaction, returning that total only after commit — the repo knows nothing of the mirror. The Store then removes resolved keys from its mirror and sets `untracked_active_count = total_active - active.len`; on any failure it changes neither.
|
||||
- **Key canonicalization happens at every entry point.** `report`, `resolve`, `reportResolved` and each `kept_keys` element of `resolveExcept` all pass the caller's key through the same digest-if-over-length rule before any lookup or SQL. Tests cover a long-key report resolved with the same long key, and `resolveExcept` keeping a long kept key.
|
||||
- **All reads go through the Store.** The handler calls `store.selectEvents` / `store.selectOne`, which lock the same mutex around `events_repo` — nothing touches `store.database` from outside. One connection, one owner.
|
||||
- `report` upserts on the active row: present → `last_seen = now_s`, `occurrences += 1`, `detail` replaced, severity raised to the worse of the two, never lowered. Absent → insert new active row and add to `active`.
|
||||
- `resolve` on an active row sets `resolved_at = now_s` and removes it from the mirror. A later failure inserts a NEW row (new episode) — the partial unique index enforces one active row per key.
|
||||
- `reportResolved` inserts a row with `resolved_at = first_seen = last_seen = now_s`, `occurrences = 1`, and never touches `active` (one-shot events: `query_log_recreated`).
|
||||
- `resolveExcept(code, kept_keys)`: resolves every active row of `code` whose `subject_key` is not in `kept_keys`, in one serialized operation. Exists for the boot-finalized codes (S2); nothing else may use it.
|
||||
- Write failures: on `db.Error`, set `write_failed = true`, increment `write_failures`, drop the event. **Log only on the `false → true` transition** — a broken diagnostics database plus a busy pool must not produce warnings at query rate; the counter and the health surface carry the ongoing state. The next successful write clears the latch (and that recovery may log once).
|
||||
- **No error propagates to a producer** — mutating methods return `void` by design; a diagnostics failure must never break the subsystem reporting it. `init` is the exception: it returns `db.Error`, and `app.zig` responds by running with no store (`null` everywhere) and logging once — a store built on an unverified mirror would produce false no-op resolves, which is worse than no store.
|
||||
- `subject_key` identity is exact at any length: a key at or under `max_subject_key_len` is stored verbatim; a longer one (operator URLs are unbounded — `safe_url.zig:12` imposes no input limit, and `manager.zig:179`'s 255 cap bounds only a display copy) is replaced by `"sha256:" ++ hex(SHA-256(key))` — 71 bytes, deterministic, collision-free in practice, so distinct long URLs never merge and the same URL always maps to the same episode. Never truncate and never reject a key. `subject_label` truncates to `max_subject_label_len`, `detail` to `max_detail_len` — those are display fields. `subject_key` never leaves the process; `subject_label` is the redacted display identity (`safe_url.redactQuoted` where the subject is a URL).
|
||||
- `prune`: delete resolved rows older than `resolved_retention_s`, then oldest resolved rows beyond `max_resolved_rows`. Active rows are never pruned. Called at store init and once per retention pass (S2).
|
||||
|
||||
### S1.4 Repository — `src/storage/repositories/events_repo.zig` (new)
|
||||
|
||||
Free functions on `*db.Db`, matching `upstream_history_repo.zig` conventions (file-scope SQL constants, by-value row structs with fixed buffers, prose test names, `:memory:` fixtures). Functions: `insertActive`, `touchActive`, `resolveActive`, `resolveActiveByKey`, `selectActiveId`, `countActive`, `resolveExcept`, `insertResolved`, `loadActive`, `selectEvents(filter)`, `selectOne(id)`, `pruneResolved`, `activeCounts` (the three beyond the original list serve the exact-overflow contract). Only the `Store` calls these in production. `selectEvents` filter: `state` (active/resolved/all), `severity`, `component` (matched on `code` prefix), `since`/`until` with the repo's `[since, until)` convention: an empty range (`since >= until`) returns nothing, and overlap is `first_seen < until AND (resolved_at IS NULL OR resolved_at > since)` — strict `>`, an episode resolved exactly at `since` does not overlap (matches `queries_repo.zig:211`). `limit`, `before` (keyset on id descending).
|
||||
|
||||
### S1.5 API — `src/web/handlers/diagnostics.zig` (new)
|
||||
|
||||
`GET /api/diagnostics` — params `state` (`active`|`resolved`|`all`, default `all`), `severity` (`warning`|`error`), `component`, `since`, `until`, `limit` (1..1000, default 100), `before` (positive id). 400 with a naming message on any bad param (copy `queries.zig` conventions: `parseFilter` + `message(err)`). 503 when the store is absent. Response:
|
||||
|
||||
```json
|
||||
{ "events": [ { "id": 42, "code": "blocklist.refresh", "component": "blocklist",
|
||||
"subject": "StevenBlack", "severity": "warning",
|
||||
"first_seen": 1787118000, "last_seen": 1787118300, "occurrences": 3,
|
||||
"resolved_at": null, "detail": "download failed: ConnectionTimedOut" } ],
|
||||
"next_before": null,
|
||||
"active": { "warnings": 1, "errors": 0 } }
|
||||
```
|
||||
|
||||
`subject` serializes `subject_label`. `subject_key` has no wire form — assert that in a test. Pagination contract identical to `/api/queries` (full page carries cursor, short page nulls it).
|
||||
|
||||
`GET /api/diagnostics/{id}` — the same event object, 404 after retention or for an unknown id.
|
||||
|
||||
Routes: two entries in `routes.zig`, `.auth = .session`, `.policy = .read`. Update the pinned route-count test (`routes.zig:145`, 56 → 58) and every routing invariant test that enumerates. OpenAPI: paths + `DiagnosticsPage` / `DiagnosticEvent` schemas, following the `/api/queries` exemplar; the openapi drift tests must stay green.
|
||||
|
||||
### S1.6 Health — `src/web/handlers/health.zig`
|
||||
|
||||
`Input` gains `diagnostics_present: bool = true` (benign default, matching the all-defaulted convention), `diagnostics_write_failed: bool = false`, `diagnostics_active_warnings: u32 = 0`, `diagnostics_active_errors: u32 = 0`. Body gains `"diagnostics": { "state": "recording"|"unavailable", "active_warnings": N, "active_errors": N }` — `unavailable` when `!present` or `write_failed`. `degraded` adds `diagnostics_write_failed or !diagnostics_present`: in a serving process the store is absent only when `Store.init` failed, which is a real degradation, and the health endpoint never runs in subcommands. `collect` must assign `diagnostics_present = state.events != null` explicitly — the natural `if (state.events) |store|` shape would leave an absent store reported as recording under the benign default. **This milestone does NOT yet remove `history_flush_failing` or restructure health to the full redesign shape** — that lands with step 4 (Overview replacement); here health only gains the diagnostics block. Update the degraded-matrix test to cover both new inputs.
|
||||
|
||||
### S1.7 Metrics — `src/web/metrics.zig`
|
||||
|
||||
Gauges `nxdns_diagnostics_active_warnings`, `nxdns_diagnostics_active_errors`; counter `nxdns_diagnostics_write_failures_total` (increment in the store on each failed write). Follow the upstream-history block pattern.
|
||||
|
||||
### S1.8 WebState — `src/web/server.zig`
|
||||
|
||||
`events: ?*events.Store = null`.
|
||||
|
||||
### S1.9 Acceptance criteria (S1)
|
||||
|
||||
- [ ] `zig build test` green; new store tests cover: episode open/dedupe/severity-raise, resolve-then-new-episode, one-shot insert, no-op resolve executes no SQL, write-failure latch set and cleared (log only on transition), label/detail truncation, over-length subject_key digested to the same identity twice, prune retention + cap, overflow slow paths for report (upsert, no unique-index collision) and resolve (count decrements), resolveExcept keeping mirror and count exact.
|
||||
- [ ] `events_repo` tests cover every function against `:memory:` including overlap-window selection and keyset pagination.
|
||||
- [ ] Handler tests: each param rejected with 400 + naming message, 503 without store, pagination cursor behavior, active counts, 404 on `{id}`.
|
||||
- [ ] Route-pin, openapi-drift, health-matrix, schema-count tests all updated intentionally and green.
|
||||
- [ ] Export/import round-trip leaves `operational_events` intact (test).
|
||||
|
||||
---
|
||||
|
||||
## Session S2: emitters (after S1)
|
||||
|
||||
Thread the store as `?*events.Store` using the established `gate: ?*disk_monitor.Monitor` idiom (`app.zig:776`): per-call parameter for run-loops, optional post-init field for `Manager`/`Pool`/`CertStore`. Every subsystem must build and test with `null` (no store). app.zig opens the dedicated connection right after migration (`app.zig:335`), unconditionally — not gated on `cfg.web.enabled`; diagnostics record whether or not the UI is on:
|
||||
|
||||
```zig
|
||||
var events_db = try data.openConfigDb(io);
|
||||
defer events_db.close();
|
||||
const boot_now = std.Io.Clock.real.now(io).toSeconds();
|
||||
var event_store_storage: ?events.Store = events.Store.init(io, &events_db, boot_now) catch |err| blk: {
|
||||
log.warn("diagnostics store unavailable: {s}", .{@errorName(err)});
|
||||
break :blk null;
|
||||
};
|
||||
const event_store: ?*events.Store = if (event_store_storage) |*s| s else null;
|
||||
```
|
||||
|
||||
`event_store` is what gets threaded; a failed init leaves it null and health reports `unavailable` + degraded.
|
||||
|
||||
Emit sites, from the verified survey. Each row: failure emit, recovery resolve, subject_key / subject_label, severity.
|
||||
|
||||
| Code | Failure (file:line today) | Recovery | subject_key → label | Severity |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `disk.space` | `disk_monitor.zig:137-147` transition to warn/critical | same `publish`, transition to ok | `"data"` singleton | warn→warning, critical→error |
|
||||
| `disk.probe` | `disk_monitor.zig:94-97` (change `catch {}` → `catch \|err\|`), `:101-105`, `:109-113` | next successful branch of the same probe | operation name (`statvfs`/`data_dir`/`log_dir`) | warning |
|
||||
| `blocklist.refresh` | `manager.zig:1102-1138` (fetch/compile/empty), `:1520-1522` (load) | `SourceStatus.succeed` paths `:840`, `:870`, `:1552` | source URL → source name | warning |
|
||||
| `blocklist.snapshot` | `app.zig:572-577`, `manager.zig:1164-1167`, `:1184-1187` | `manager.zig:539-544` (post-swap) | singleton | error |
|
||||
| `blocklist.storage` | `manager.zig:1200-1203`, `:1336`, `:1451`, `:1458`, `:1470` | **the success branch of each matching operation, NOT pass-end** — `deleteQuietly` absorbs failures at `manager.zig:1466` and `pruneOrphans` still returns success at `:1350`, so a pass-end resolve would close the very event its own pass emitted | operation name | warning |
|
||||
| `certificate.reload` | `cert_store.zig:256-262` (capture the discarded err), `:273-279` | `:271-273` success reload | endpoint kind — **`CertStore` gains a `kind: enum { doh, dot }` field set at `openCertStore` (`app.zig:611/:617`)** | warning — a stat failure can be a transient rename window and a failed reload keeps the loaded certificate serving (`cert_store.zig:250`); nothing is down |
|
||||
| `query_log.write` | `logger.zig:252-260` (init, from the failing `BatchWriter.init` and its catch), `:400-404` (batch) | `:405` successful batch. Init failure has no recovery (writer exits) — the event stays active, which is the truth | `writer`/`batch` | error |
|
||||
| `query_log.maintenance` | `retention.zig:113`, `:127`, `:133`, `:143`, `:151` | matching success branch same pass | operation name | warning |
|
||||
| `query_log.recreated` | `querylog_schema.open` result. **Two changes: `OpenResult` gains the aside name (fixed `[path_buf_len]u8` + len — today it exists only in a stack buffer inside `open`, `querylog_schema.zig:137`), and `cli.openQuerylogDb` (`cli.zig:335`) stops discarding `recreated` and returns it to `app.zig`**, which calls `reportResolved` once the store exists. **Never emitted for `.missing`** — first creation has no aside file and is logged as "created" (`querylog_schema.zig:131`); a fresh install must not record a warning with an impossible aside path | one-shot | reason tag; detail carries the aside filename | warning |
|
||||
| `upstream_history.write` | `history.zig:262-266` | `:257-261` | singleton | warning |
|
||||
| `upstream.exchange` | `pool.zig:332-346` `recordFailure` — emit OUTSIDE the pool mutex, same placement discipline as `recordHistory` | `pool.zig:316-330` `recordSuccess` → `resolve` (no-op hot path) | upstream URL → `safe_url.redactQuoted` | warning |
|
||||
| `client_names.storage` | `client_names.zig:125-131`, `:141-147` | clean-pass determination `client_names.zig:161-164` | `read`/`write` | warning |
|
||||
| `clients.storage` | `clients.zig:196-202`, `:218-224` | success branches `:195-197`, `:214-217` | `materialise`/`prune` | warning |
|
||||
| `listener.start` | `app.zig:896/:900` (DoH), `:915/:919` (DoT) | boot-finalized: after the bind phase, one `resolveExcept(.listener_start, failed_keys)` closes prior episodes for endpoints that started clean this boot — including endpoints now disabled | `doh`/`dot` | error |
|
||||
| `configuration.load` | `app.zig:686`, `:1039-1045`, `:1056-1061` and managed-file `validate.Diagnostics` warnings rendered at `app.zig:204` | boot-finalized: after all boot findings are emitted, one `resolveExcept(.configuration_load, emitted_keys)` | setting/upstream identity (redacted) | warning |
|
||||
|
||||
Rules that bind every site:
|
||||
|
||||
- The existing `log.warn`/`log.err` lines STAY. Events are additive; journald keeps the raw stream.
|
||||
- Failure text passed as `detail` is `@errorName(err)` plus the site's existing message fragment — no new prose invented, no allocation: format into a stack buffer of `Store.max_detail_len`.
|
||||
- **Lock discipline is collect-then-flush, not "emit after the mutex closes".** Several manager sites cannot simply move: `publishRefresh` runs under `writer_lock` by contract (`manager.zig:792`) and `pruneOrphans` holds both `refresh_lock` and `writer_lock` through its filesystem work (`manager.zig:1299`). At such sites, record outcomes while the locks are held and flush to the store after the outer locked operation returns. The collection must not lose events: per-source outcomes ride the manager's existing per-source status allocations (one outcome per source, bounded by the source count), and same-operation failures within one pass aggregate into a single `report` per pass (the row's `occurrences` then counts failing passes, and `detail` carries the last error plus how many failures that pass held) — never an unbounded list, never a silent drop. Pool follows the existing `recordHistory` placement.
|
||||
- Boot-finalized codes (`listener.start`, `configuration.load`) use `resolveExcept` exactly once each, after their boot phase completes; restart is the recovery, matching the design table.
|
||||
- `retention.zig` `runOnce` gains `events: ?*events.Store` and calls `store.prune` once per pass.
|
||||
|
||||
### S2 acceptance criteria
|
||||
|
||||
Emitters fall into three classes, each with its own test obligation (the author watches each fail first, repo ruling F-f):
|
||||
|
||||
- **Episodic** (disk, blocklist ×3, certificate, query_log.write batch, query_log.maintenance, upstream_history.write, upstream.exchange, client_names, clients): force the failure (existing seams: injected db errors, missing files, dead fetch server), assert the `(code, subject_key, severity)` row; then force recovery and assert resolution.
|
||||
- **Boot-finalized** (`listener.start`, `configuration.load`): assert the emit on a failing boot, and assert `resolveExcept` closes a pre-seeded stale episode on a clean boot.
|
||||
- **One-shot / permanent** (`query_log.recreated`; `query_log.write` init): assert the one-shot row inserts already resolved; assert the init-failure episode exists and that no recovery path claims it.
|
||||
- [ ] `pool` exchange tests: success with no active episode performs no store SQL; failure→success round-trip produces exactly one resolved episode with correct occurrences.
|
||||
- [ ] All subsystems still pass with `events = null` (the existing suites running unchanged).
|
||||
|
||||
---
|
||||
|
||||
## Session S3: admin SPA (after S1, parallel with S2)
|
||||
|
||||
- `lib/types.ts`: `DiagnosticEvent`, `DiagnosticsPage`, plus the health body's new `diagnostics` block.
|
||||
- `lib/api.ts`: `getDiagnostics(filter)`, `getDiagnostic(id)`.
|
||||
- `lib/queries.ts`: `diagnosticsQuery` (infinite, keyset via `next_before`, `keepPreviousData`), `diagnosticQuery(id)`; refetch interval matching healthQuery's cadence for the active view.
|
||||
- `routes.tsx`: `/diagnostics` route (loader + lazy component) and `/diagnostics/$id`. Nav: add `Diagnostics` to `NAV_ITEMS` between Lookup and Settings (full nav restructure is later milestones); update `AppShell.test.tsx`.
|
||||
- `features/diagnostics/DiagnosticsPage.tsx`: active episodes first (severity, title from an exhaustive code→copy map, subject, "active for …" age, occurrences), then resolved history with the standard filters (state, severity, component) reflected in the URL search params. **Empty state is the healthy state**: one line, "No active operational issues.", quiet styling — it must read as good news, not as a broken page (Fable's caution).
|
||||
- `features/diagnostics/DiagnosticDetailPage.tsx`: the ordered detail from the design — state, first/last seen, occurrences, resolution, impact and remediation text from the same exhaustive code map, last error detail, and links (the config surfaces linked are today's routes; they get re-pointed when later milestones move them).
|
||||
- The code→copy map lives in `features/diagnostics/eventCopy.ts`: an exhaustive `Record<Code, {title, impact, remediation, link?}>` over a string-literal union of the fifteen wire codes; a test iterates the union and asserts every member has copy.
|
||||
- Tests: page renders fixtures (active + resolved + empty), filters drive the URL, detail renders every code's copy, pagination fetches more.
|
||||
|
||||
### S3 acceptance criteria
|
||||
|
||||
- [ ] `npm run typecheck`, `npx vitest run`, `npx prettier --check`, lint, build, assert-bundled all green; byte budget respected.
|
||||
|
||||
---
|
||||
|
||||
## Orchestrator integration (after S2+S3)
|
||||
|
||||
- Regenerate `contractSamples.gen.ts` (`zig build test -Dintegration -Dcontract-samples-out=…`) — requires the integration capture in `web_integration_test.zig` to seed at least one active and one resolved event.
|
||||
- Update `docs/reference/api.md` and any drift-guarded reference pages; changelog entry under Unreleased.
|
||||
- Live smoke on the real binary before anything ships: force a blocklist failure (dead URL), watch the episode appear, fix the URL, watch it resolve; screenshot the page per standing rule.
|
||||
|
||||
## Module layout (new files)
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `src/storage/events.zig` | `Code`, wire mapping, `Store` |
|
||||
| `src/storage/repositories/events_repo.zig` | SQL |
|
||||
| `src/web/handlers/diagnostics.zig` | list + detail handlers |
|
||||
| `admin/src/features/diagnostics/…` | page, detail, copy map, tests |
|
||||
|
||||
## File ownership
|
||||
|
||||
S1: `config_schema.zig`, `events.zig`, `events_repo.zig`, `diagnostics.zig` (handler), `routes.zig`, `openapi.yaml`, `health.zig`, `metrics.zig`, `server.zig`, `migrations.zig` (bridge + test), PLAN §11.2. S2: every emitter file + `app.zig` + `retention.zig` + `cert_store.zig` + `cli.zig` (openQuerylogDb) + `querylog_schema.zig` (OpenResult aside name) and its tests — S2 starts after S1 lands, so nothing is shared in parallel. S3: `admin/` only. No parallel writers on any file (S2 and S3 run concurrently and are disjoint).
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No `info` severity, no acknowledgement/dismissal state, no manual-resolve endpoint, no raw-log endpoint, no generic remediation action schema.
|
||||
- No `api.storage` code. No periodic probe for `write_failed`. No configurable retention.
|
||||
- No changes to the existing dashboard, query log, live, or lookup pages.
|
||||
- No new dependency, front or back.
|
||||
|
||||
## Acceptance criteria (milestone complete)
|
||||
|
||||
- [ ] All session criteria; full `zig build test` + `-Dintegration`, admin suite, byte budgets.
|
||||
- [ ] Live smoke: forced failure → visible episode → recovery → resolved row; `/api/health` shows the diagnostics block; `/metrics` shows the three series.
|
||||
- [ ] Screenshots of the page (active, resolved, empty states) shown before push.
|
||||
|
||||
## Recorded (as built)
|
||||
|
||||
Deviations from the sections above, found during build, review and live smoke. The code is the authority; this section says where it moved.
|
||||
|
||||
- `src/storage/events_fixture.zig` exists (test-only): a shared migrated-config.db + Store fixture the storage and filter tests use.
|
||||
- The threading fields through `app.zig`/`server.zig` are named `.diagnostics`; `WebState` carries `.events`. The live smoke caught that `.events` was never assigned in `app.zig` while every suite stayed green — the integration tests build their own `WebState`. One line fixed it; the lesson is the standing "verify against the real network" rule.
|
||||
- `runScheduler`'s loop body is the pub function `scheduledPass`, so tests drive one pass deterministically. `runScheduler` flushes only in its catch branch (for the snapshot note its failure path adds).
|
||||
- Occurrences count failing passes, not flushes: `SourceStatus` carries `pass_outcome`/`pass_failures`, set in `fail()`/`succeed()`, drained by `flushSourceDiagnostics`. Detail format: `"{state}: {error} ({N} this pass)"`.
|
||||
- Pass accounting lives in exactly one table copy at a time: `mergeStatuses` zeroes the pass fields on carried entries, `installStatuses` folds the live table's unflushed accounting in by id under the exclusive lock, and the flush drains by claiming one outcome-bearing entry per lock round (immune to concurrent table replacement).
|
||||
- A pass flushes before it releases the lock that serializes it: the flush defer registers after the unlock defer in `refreshSource`, `refreshAll`, `startupPass`; standalone `reload` takes `writer_lock` itself and flushes inside it. This amends the collect-then-flush rule — the store runs on its own connection and mutex, so no shared resource is held across the store call.
|
||||
- Deleted sources: `flushSourceDiagnostics` ends in `resolveDeletedSources` — `resolveExcept(.blocklist_refresh, all current keys)`, gated on `generation != 0` (an empty pre-reload table means "not read yet", not "all deleted") and skipped above 64 keyed entries (`max_kept_keys`; the episode then lingers until the count drops). `Store.resolveExcept` is no longer boot-finalized-only; the rule is the kept list must be the whole current subject set.
|
||||
- `events_repo.resolveExcept` is mark-then-unmark (sentinel `maxInt(i64)`, one transaction): the prior resolve-all-then-revive matched the revive on `resolved_at = now_s` and revived an episode resolved by the drain in the same second.
|
||||
|
||||
### Addendum: manual purge of resolved events
|
||||
|
||||
Resolution stays automatic; the operator decides when resolved history disappears. Two endpoints, both mutations under the usual auth, both allowed in file mode (diagnostics are runtime state, not configuration):
|
||||
|
||||
- `DELETE /api/diagnostics/{id}` — purges one resolved event. 409 `{ "error": ... }` when the event is active; 404 when no row has that id.
|
||||
- `DELETE /api/diagnostics` — purges every resolved event, returns `{ "purged": N }`.
|
||||
|
||||
Store: purge functions under the store mutex; they touch only rows with `resolved_at NOT NULL`, so the ActiveSet mirror and untracked count never change. UI: a purge action on each resolved row and on the resolved detail page, plus a "Purge all resolved" control on the list when at least one resolved event shows; active events show no purge affordance. OpenAPI, routes count, contract goldens, api.md updated.
|
||||
|
||||
Accepted limitations (reviewed with Codex, ruled in proportion to household scale; each is bounded and self-correcting, counts stay visible in the detail text):
|
||||
|
||||
- A standalone web reload interleaving with a refresh pass can merge two passes' accounting into one occurrence.
|
||||
- `pruneOrphans` flushes its storage aggregates with the writer locks released; two concurrent prunes can merge into one report.
|
||||
- The deletion sweep's kept-key snapshot can go stale against a concurrently added source: its fresh episode can be resolved once and reopens on the next failing pass with `first_seen`/`occurrences` reset.
|
||||
@@ -0,0 +1,304 @@
|
||||
# UI redesign proposal
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
Five primary items. Configuration expands to three task-shaped subpages and holds no landing route of its own.
|
||||
|
||||
| Navigation | Route | Operator question | Replaces |
|
||||
| --- | --- | --- | --- |
|
||||
| Overview | `/overview` | Is DNS healthy and protecting the household now, and what happened in this period? | Dashboard |
|
||||
| Activity | `/activity` | What requests are happening or happened, and why did nxdns handle them that way? | Query Log, Live, Lookup |
|
||||
| Clients | `/clients` | Who is this address, which policy applies, and what has it been querying? | Clients |
|
||||
| Diagnostics | `/diagnostics` | What is failing or has failed, what is affected, what should I do? | new |
|
||||
| Protection | `/configuration/protection` | What policy governs each group, and which rules and lists produce it? | Groups, Blocklists, Rules |
|
||||
| Resolution | `/configuration/resolution` | Where does nxdns answer or forward permitted names? | Local DNS, Upstreams |
|
||||
| System | `/configuration/system` | What service, storage, logging, TLS and web settings is this process running with? | Settings |
|
||||
|
||||
Secondary routes, reached from those surfaces rather than the nav: `/activity/queries/:id`, `/activity/test`, `/clients/:id`, `/diagnostics/:id`.
|
||||
|
||||
No current page survives unchanged. Login, logout and pause survive functionally, restyled into the new shell.
|
||||
|
||||
### Activity
|
||||
|
||||
Two modes over the same columns and filters. History is persisted queries with keyset pagination and an absolute range. Live is follow-by-default with Freeze/Resume over the existing bounded 500-row buffer.
|
||||
|
||||
Columns: Time, Domain, Client, Type, Result, Route, Duration. Rule matches, source URLs and upstream errors live in the detail view, never on every row.
|
||||
|
||||
Domain testing stays as an Activity action labelled "Current policy simulation". It must never read as an explanation of a historical query.
|
||||
|
||||
### Protection
|
||||
|
||||
Group-centred: group list, selected group detail, effective safe-search setting, assigned blocklist sources, rules scoped to the group, client count linking to matching clients. A Sources tab holds the shared blocklist catalogue and the "Update now" runtime action.
|
||||
|
||||
### Resolution
|
||||
|
||||
Three tabs: upstream pool, local records, forward zones.
|
||||
|
||||
### Clients
|
||||
|
||||
Keeps primary navigation because identifying and naming unknown devices is an operational job, not configuration. The *learned* marker appears only here, beside the name. Prefix assignments live here as "Network assignments".
|
||||
|
||||
## Overview
|
||||
|
||||
Three sections, nothing else.
|
||||
|
||||
**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.
|
||||
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
If the selected period predates available data, the section says "Query history is available from …" rather than charting the missing span as zero.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
### Event model
|
||||
|
||||
One row is one failure episode.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `id` | Durable identifier |
|
||||
| `code` | Fixed machine-readable kind |
|
||||
| `subject_key` | Internal stable identity, may hold a full URL, never serialized |
|
||||
| `subject_label` | Bounded, redacted, operator-facing identity |
|
||||
| `severity` | `warning` or `error` |
|
||||
| `first_seen` / `last_seen` | Episode bounds |
|
||||
| `occurrences` | Deduplicated report count |
|
||||
| `resolved_at` | Null while active |
|
||||
| `detail` | Bounded last error or current condition |
|
||||
|
||||
No `info` severity. Normal starts, refreshes and reloads do not become entries; a success resolves its prior failure.
|
||||
|
||||
Active dedup key is `(code, subject_key)`. A repeat updates `last_seen`, `occurrences`, severity and detail. A success resolves the row. A later failure opens a new episode rather than reopening the old one. Severity records the worst state reached. There is no acknowledgement or manual dismissal: active means the component has not demonstrated recovery. One-shot material events, such as a query-log recreation, are inserted already resolved.
|
||||
|
||||
### Storage
|
||||
|
||||
Events live in `config.db` as runtime state, excluded from export, import and file reconciliation. Active rows are never pruned. Resolved rows keep 90 days, with a hard cap of the newest 5,000. `detail` caps at 512 bytes. Pruning runs at startup and from existing maintenance; no new scheduler.
|
||||
|
||||
One table, one repository, one fixed event-code enum. A small serialized event store owns a dedicated `config.db` connection; background producers report synchronously through its mutex. This is the existing pattern, not a new one: `app.zig:333` opens a connection for migration and reconciliation and `app.zig:544` opens a separate `web_config_db`, and the background producers (fetcher, disk monitor, logger writer) have no other safe path into `config.db`.
|
||||
|
||||
If the store itself cannot write, an atomic `event_store_failed` state appears in `/api/health` and journald. It clears on the next successful write — no periodic probe. The store receives a write whenever anything fails or recovers, and a flag left set while nothing needs writing costs nothing.
|
||||
|
||||
### Event sources
|
||||
|
||||
| Source | Event identity | Recovery |
|
||||
| --- | --- | --- |
|
||||
| Disk warn/critical transitions | `disk.space`, singleton | next `ok` sample |
|
||||
| Failed `statvfs` or directory sizing | `disk.probe`, keyed by operation/path | next successful probe |
|
||||
| Per-source download, HTTP, parse, compile or file-read failure | `blocklist.refresh`, keyed by source URL | that source refreshes |
|
||||
| Initial snapshot or whole-pass failure | `blocklist.snapshot`, singleton | a snapshot publishes |
|
||||
| Blocklist file cleanup failure | `blocklist.storage`, keyed by operation | that operation succeeds |
|
||||
| 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 |
|
||||
| Enabled DoH/DoT listener that cannot start | `listener.start`, keyed by endpoint | successful start after restart |
|
||||
| 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.
|
||||
|
||||
An upstream event describes a consecutive failure episode, not one row per retry. One timeout followed by success is one resolved episode.
|
||||
|
||||
Outside Diagnostics: invalid requests, conflicts, rate limits and failed logins already answered to their caller; expected reverse-DNS outcomes; individual TLS handshake failures already counted; development asset-server warnings; fatal startup failures that stop the UI existing. Disk-gated skips do not duplicate — the active disk event explains the cause and counters keep the totals.
|
||||
|
||||
### From event to remediation
|
||||
|
||||
The detail shows current or resolved state; first seen, last seen, occurrences, resolution time; impact in operator language; the last bounded underlying error; the exact next action; how nxdns will verify recovery; links to the relevant configuration and activity window.
|
||||
|
||||
The frontend uses one exhaustive `switch` over the fixed codes for titles, impact, remediation and routes. No generic action schema, no plugin mechanism.
|
||||
|
||||
## Query provenance
|
||||
|
||||
Stays in the expendable `querylog.db`. Does not go into the operational-events table.
|
||||
|
||||
`block_reason` is replaced by the fuller `policy_reason`. New columns:
|
||||
|
||||
| Column | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `qclass` | INTEGER NOT NULL | explains filtering bypass for non-IN questions |
|
||||
| `rcode` | INTEGER NOT NULL | the client-visible result, including SERVFAIL |
|
||||
| `group_id` | INTEGER | group at query time, not a foreign key |
|
||||
| `group_name` | TEXT | historical label, survives a rename |
|
||||
| `policy_action` | TEXT NOT NULL | `not_evaluated`, `allow`, `block` |
|
||||
| `policy_reason` | TEXT NOT NULL | pipeline or matcher reason |
|
||||
| `matched` | TEXT | exact rule pattern or list entry |
|
||||
| `source_id` | INTEGER | blocklist source at query time |
|
||||
| `source_name` | TEXT | historical source label |
|
||||
| `cname_target` | TEXT | target that caused an uncloaked block |
|
||||
| `safe_search_target` | TEXT | name used for the rewrite |
|
||||
| `route_kind` | TEXT NOT NULL | `blocked`, `local`, `forward_zone`, `upstream` |
|
||||
| `forward_zone` | TEXT | exact matched zone |
|
||||
|
||||
`upstream` changes from the unhelpful `"pool"` marker to the actual configured upstream or forward resolver **on the exchange that actually happened**. On a cache hit `route_kind` is `cache` and `upstream` is NULL. Credentials are redacted at the serialization boundary.
|
||||
|
||||
Codex wanted the upstream recorded on cache hits too. Cut on review: it would widen every `src/cache/dns_cache.zig` entry to carry an upstream label, and it states a half-truth, because on a cache hit no upstream answered. The interface change it does need is real and worth doing — `handler.zig:79-83` records ruling 20, which deliberately keeps the pool's answering endpoint out of `transport.Client`'s reach. Exposing it touches `src/upstream/`, not the pure core. Do not fall back to `"pool"`: the exact upstream on a SERVFAIL row is the single most useful correlation this redesign adds.
|
||||
|
||||
`policy_reason` is a closed enum: `local_record`, `forward_zone`, `non_in_class`, `paused`, `snapshot_unavailable`, `no_match`, plus the existing rule allow/block and blocklist exception/domain/wildcard reasons.
|
||||
|
||||
For a CNAME-uncloaked block, the policy fields describe the target's decision and `cname_target` preserves the target responsible.
|
||||
|
||||
Every syntactically parsed request that receives a response is logged, including synthesized SERVFAIL. Requests too malformed to identify a question stay counters, not fabricated rows.
|
||||
|
||||
No response payloads, answer RR sets, EDNS data or packet bytes are stored. The record explains nxdns's own decision, not the external resolver's answer.
|
||||
|
||||
Privacy transforms apply to every new domain-bearing field, not only `domain`: with `hide_domains` on, matched names, CNAME targets and safe-search targets hide consistently.
|
||||
|
||||
A one-row `querylog_meta (created_at INTEGER NOT NULL)` table lets the stats and query APIs return a conservative `available_since`, which distinguishes "zero queries" from "history does not exist".
|
||||
|
||||
### Historical query detail
|
||||
|
||||
Ordered explanation: request (time, domain, client, type, class); group (historical id and name); policy (evaluated or not, allow/block, exact matched rule or list candidate, historical source, whether filtering was paused or unavailable); rewrites (safe-search target, CNAME target); route (local, forward zone, cache, or selected upstream); response (rcode, duration); related actions (test the domain against current policy, view the client, view activity for the same domain or client, view diagnostics in a five-minute window around the query).
|
||||
|
||||
Historical and current facts are visually separated. A rule, source or group that no longer exists stays visible as a historical value and is not linked to a different current object.
|
||||
|
||||
Live SSE events carry the same provenance shape without a persisted `id`. A frozen live row shows its in-memory detail; no correlation id is invented to link it to a row SQLite has not written.
|
||||
|
||||
## Time scoping
|
||||
|
||||
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.
|
||||
|
||||
## File mode
|
||||
|
||||
One persistent authority line in the shell:
|
||||
|
||||
> File-managed · `/etc/nxdns/config.zon` · loaded 19 Aug 2026, 08:42
|
||||
|
||||
It sits in the Configuration sub-navigation and appears elsewhere as a compact lock indicator. No full-width banner on every route. The wording is "running configuration loaded from", not "file contents" — the server cannot prove a since-edited file still matches the running process.
|
||||
|
||||
A file-managed page uses definition lists for scalars and tables or cards for collections, with human labels and the exact ZON key shown secondarily (`logging.retention_days`). No text inputs, no checkboxes, no Add/Edit/Delete, no disabled form shells, no simulated Save. A short page note says where edits happen and that a restart may be needed.
|
||||
|
||||
Runtime actions stay ordinary enabled buttons: pause/resume, update blocklists now, reload certificates, delete an observed undeclared client, login/logout.
|
||||
|
||||
Database mode uses the same information architecture with real edit actions, plus a server-owned `restart_pending` boolean. The current client-only restart banner and its local store are removed, so a browser refresh cannot erase the warning.
|
||||
|
||||
## API changes
|
||||
|
||||
`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/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.
|
||||
|
||||
`GET /api/queries` keeps keyset pagination and its filters; rows gain `rcode`, `route_kind`, `policy_action` and the short policy reason the table needs, and the body gains `coverage: {complete, available_since}`. `GET /api/queries/{id}` returns nested `request` / `policy` / `route` / `response` provenance. `GET /api/queries/live` sends the same object without `id`.
|
||||
|
||||
`GET /api/stats` and `/api/stats/timeseries` add `complete` and `available_since`.
|
||||
|
||||
Existing mutation endpoints stay specific. Diagnostics introduces no generic "perform remediation" endpoint; it invokes the existing blocklist-refresh and certificate-reload operations.
|
||||
|
||||
## Schema changes
|
||||
|
||||
`config.db`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE operational_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
subject_key TEXT NOT NULL,
|
||||
subject_label TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
occurrences INTEGER NOT NULL CHECK (occurrences > 0),
|
||||
resolved_at INTEGER,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
CHECK (resolved_at IS NULL OR resolved_at >= first_seen)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_operational_events_active
|
||||
ON operational_events(code, subject_key)
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_operational_events_last_seen
|
||||
ON operational_events(last_seen DESC);
|
||||
```
|
||||
|
||||
Absent from the configuration table lists and the reconciliation delete order.
|
||||
|
||||
`querylog.db`: `querylog_meta` and the provenance columns above. No new index — Codex proposed `idx_query_log_rcode`, cut on review, because every rcode question the UI asks is time-scoped and `idx_query_log_ts` already bounds the scan. 700k rows on a Pi 5 do not need a second index for a rare filter, and every index taxes the hot insert path in `logger.zig`.
|
||||
|
||||
No new provenance table, no key/value store — household retention makes nullable columns cheaper than a normalized graph of decision objects.
|
||||
|
||||
### Entry buffer widths
|
||||
|
||||
`logger.zig`'s `Entry` uses fixed buffers sized by `max_reason_len = 32` and `max_upstream_len = 64`, and travels through the `Io.Queue` by value. The new fields roughly triple it: `matched` holds a full pattern, and `cname_target`, `safe_search_target` and `forward_zone` each hold up to 253 bytes. That is fine at `flush_batch = 100`, but the widths are part of this contract and must be set explicitly, not left to whatever the first implementation picks.
|
||||
|
||||
`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.
|
||||
|
||||
## Deletions and their cost
|
||||
|
||||
| Deleted | Lost |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
| "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 |
|
||||
| Ephemeral blocklist `SourceStatusSection` | transient success detail after navigation; durable counters and diagnostics remain |
|
||||
| Disabled configuration forms in file mode | the illusion that fields can be edited |
|
||||
| Global read-only banner | repeated warning text; authority stays visible once in the shell |
|
||||
| Client-only restart banner state | nothing reliable; server-owned `restart_pending` replaces it |
|
||||
| Old route aliases and redirects | existing bookmarks break; no permanent duplicate routing layer |
|
||||
|
||||
## Build sequence
|
||||
|
||||
Each step leaves the app working and shippable, and updates its OpenAPI contract, generated sample, TypeScript types and deterministic tests before landing.
|
||||
|
||||
1. **Diagnostics vertical slice.** `operational_events` schema, repository, serialized store, retention, health state, every typed emitter, API, UI, navigation. Recovery paths and deterministic failure-injection tests per event code. The rest of the UI stays intact.
|
||||
2. **Query provenance vertical slice.** Query-log fingerprint and schema, metadata table, provenance capture in the handler and logger, parsed-SERVFAIL logging, detail and coverage APIs, the historical detail route, and the `query_log.recreated` emission. Existing list summary fields stay so the current pages keep working.
|
||||
|
||||
**This step destroys the existing query history.** The provenance DDL edit changes the fingerprint, so `querylog_schema.open` recreates the file and keeps the old one aside as `querylog.db.schema-changed-<unix seconds>`. Acceptable pre-v0.1, and `available_since` carries the story in the UI, but it is a consequence of this step and must be stated in its spec and its changelog entry. Its acceptance tests cover the recreate, the aside name and the coverage sequence — which is also the natural first `query_log.recreated` emission.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Codex's least-certain calls
|
||||
|
||||
- **Clients in primary navigation.** It earns the slot if identifying unknown devices and checking their group is routine. If Mokhtar almost always reaches a client from a query, Clients moves under Protection and leaves the nav.
|
||||
- **Recording the exact selected upstream per query.** It materially improves correlating SERVFAIL queries with upstream events, but needs the pool exchange result to expose the selected target. Drop back to `"pool"` only if that interface change proves invasive and exact resolver identity never changes an action.
|
||||
- **90-day / 5,000-event retention.** Conservative fixed bounds, not settings. Change only after measuring real row size and event rate on the Pi; do not add configurable retention pre-emptively.
|
||||
|
||||
## Rulings (Fable review, 2026-08-20)
|
||||
|
||||
Verdict: build it, with the four cuts folded in above and the rulings below.
|
||||
|
||||
**Diagnostics live in `config.db`. Accepted.** The `querylog.db` alternative is self-refuting: that file is recreated on any schema edit or corruption, so the recreation event dies with the thing it describes. A third database needs either its own migration discipline or a recreate policy that loses the events — the same problem with more files. The write-traffic objection is overstated twice: event volume is failure-rate volume, deduplicated, with no `info` severity, so it is near zero in steady state; and `config.db` already takes operational writes, because client rows are materialized from traffic (PLAN §3.5, `clients.last_seen`, `learned_name`). Pre-v0.1 the table is one edit to `ddl_v1` with no migration step. Keeping it out of `delete_order` and `table_names` is enforceable — `config_schema.zig` has tests pinning those lists.
|
||||
|
||||
**`group_name`, `source_name` and `matched` stay TEXT. Accepted, and my inconsistency objection was weaker than I put it.** The `domains` table exists because a domain appears on every row, runs to 253 bytes, and feeds `GROUP BY` stats. None of that holds here. The closer precedent is `client_ip`, which is TEXT with the comment "not a FK: log rows are immutable facts" (`querylog_schema.zig:37`). `group_name` is short and mostly `default`; `source_name` and `matched` are non-NULL only on blocked rows; `matched` has cardinality high enough that normalizing buys nothing. Keep both halves of each id/name pair: the id links to the same object across a rename, the name survives a delete, and the detail view needs both.
|
||||
|
||||
**Provenance capture does not violate the pure core.** Verified against the code. The policy decision is made in `src/server/handler.zig`, which is already impure and already holds `Io`, the clock and the log call. `matcher.Decision` (`matcher.zig:39-51`) already returns `reason`, `matched` and `source`; the handler throws `matched` and `source` away at `handler.zig:501`. Capture is mostly widening `logger.zig`'s `Entry` and `LogFields`, not threading state through `dns/`, `filter/`, `local/` or `cache/`.
|
||||
|
||||
**Six milestones under the harness, one per step.** Do not fold step 6 into step 5: the closure sweep regenerates the contract samples and adds the cross-surface acceptance tests, and it deserves its own verify gate. If anything needs splitting it is step 1, which touches about ten subsystems — store, API and UI first, then the emitters. An event store with three emitters is already shippable and honest.
|
||||
|
||||
**One caution, not a blocker.** Diagnostics will be an empty page most of the year. The "No active operational issues" line has to make empty read as healthy, not broken.
|
||||
Reference in New Issue
Block a user