13 Commits
Author SHA1 Message Date
mokhtar 324704b53f build: bump version to 0.0.7
Gates / package (push) Successful in 41s
Gates / test (push) Successful in 1m35s
Gates / test-aarch64 (push) Successful in 6m48s
Gates / frontend (push) Successful in 1m22s
Gates / container (push) Successful in 9s
CI / gates (push) Successful in 23m18s
Gates / frontend (push) Successful in 1m8s
Gates / package (push) Successful in 40s
Release / guard (push) Successful in 17s
Gates / test (push) Successful in 1m35s
Gates / test-aarch64 (push) Successful in 6m55s
Gates / container (push) Successful in 9s
Release / gates (push) Successful in 9m23s
Release / publish (push) Successful in 4m26s
2026-08-20 22:34:13 +02:00
mokhtar addf24f92c query log batching: one transaction per flush interval, not per query
Gates / frontend (push) Successful in 1m18s
Gates / test (push) Successful in 2m46s
Gates / test-aarch64 (push) Successful in 7m33s
Gates / package (push) Successful in 5m34s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 16m16s
Gates / frontend (push) Successful in 1m8s
Gates / container (push) Successful in 9s
Release / gates (push) Successful in 9m15s
Release / guard (push) Successful in 19s
Gates / test (push) Successful in 1m34s
Gates / test-aarch64 (push) Successful in 6m46s
Gates / package (push) Successful in 39s
Release / publish (push) Failing after 4m7s
2026-08-20 20:57:11 +02:00
mokhtar 037f209179 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
2026-08-20 20:05:59 +02:00
mokhtar 3dd8214ef2 dashboard: a success rate never rounds up to 100.0% while failures stand
Gates / frontend (push) Successful in 1m27s
Gates / test (push) Successful in 1m53s
Gates / test-aarch64 (push) Successful in 7m7s
Gates / package (push) Successful in 3m57s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m15s
2026-08-19 17:54:02 +02:00
mokhtar 64c0d723a6 querylog: the aside file's name says why, so a schema change is not called corrupt 2026-08-17 20:24:21 +02:00
mokhtar 377f00a35f changelog: 0.0.6 releases today
Release / guard (push) Successful in 22s
Gates / frontend (push) Successful in 1m30s
Gates / test (push) Successful in 1m50s
Gates / frontend (push) Successful in 1m9s
Gates / test-aarch64 (push) Successful in 6m55s
Gates / test (push) Successful in 1m34s
Gates / test-aarch64 (push) Successful in 6m9s
Gates / package (push) Successful in 3m49s
Gates / package (push) Successful in 32s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 21m28s
Gates / container (push) Successful in 9s
Release / gates (push) Successful in 20m7s
Release / publish (push) Successful in 4m26s
2026-08-17 18:22:54 +02:00
mokhtar e0a7cd8a6b milestone 26: upstream health answers for the selected period 2026-08-17 18:21:56 +02:00
mokhtar 3ed9a57822 changelog: 0.0.5 releases today
Gates / test (push) Successful in 1m44s
Gates / frontend (push) Successful in 1m2s
Gates / test-aarch64 (push) Successful in 5m55s
Gates / package (push) Successful in 28s
Gates / container (push) Successful in 16s
CI / gates (push) Successful in 20m25s
Release / guard (push) Successful in 18s
Gates / frontend (push) Successful in 1m5s
Gates / test-aarch64 (push) Successful in 6m33s
Gates / test (push) Successful in 1m31s
Gates / package (push) Successful in 3m41s
Gates / container (push) Successful in 9s
Release / gates (push) Successful in 19m27s
Release / publish (push) Successful in 5m43s
2026-08-16 23:55:19 +02:00
mokhtar 0601098ab0 admin: drop the learned tag from the query tables 2026-08-16 23:55:10 +02:00
mokhtar 49c7da2381 changelog: 0.0.4 releases today
Release / guard (push) Successful in 26s
Gates / frontend (push) Successful in 1m26s
Gates / test (push) Successful in 1m44s
Gates / frontend (push) Successful in 1m6s
Gates / test-aarch64 (push) Successful in 6m36s
Gates / test (push) Successful in 1m33s
Gates / test-aarch64 (push) Successful in 5m50s
Gates / package (push) Successful in 5m5s
Gates / package (push) Successful in 30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 21m58s
Gates / container (push) Successful in 10s
Release / gates (push) Successful in 20m42s
Release / publish (push) Successful in 4m41s
2026-08-16 23:00:41 +02:00
mokhtar 794ea6541f admin: live and query log pages show client names 2026-08-16 23:00:31 +02:00
mokhtar ba037c5958 website: plan for the nxdns.sh marketing and docs site
Gates / frontend (push) Successful in 1m8s
Gates / test (push) Successful in 2m5s
Gates / test-aarch64 (push) Successful in 7m1s
Gates / package (push) Successful in 4m3s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m28s
2026-08-16 00:17:58 +02:00
mokhtar 1e97c80f6b rename web/ to admin/, along with the web-named build and cli identifiers 2026-08-16 00:17:58 +02:00
191 changed files with 12761 additions and 815 deletions
+22 -22
View File
@@ -119,39 +119,39 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
cache-dependency-path: admin/package-lock.json
- name: Install dependencies
working-directory: web
working-directory: admin
run: npm ci
- name: Check formatting
working-directory: web
working-directory: admin
run: npm run format:check
- name: Lint
working-directory: web
working-directory: admin
run: npm run lint
- name: Typecheck
working-directory: web
working-directory: admin
run: npm run typecheck
- name: Run tests
working-directory: web
working-directory: admin
run: npm test
- name: Build
working-directory: web
working-directory: admin
run: npm run build
# The licence inventory has to cover every package whose bytes ship, and
# the lockfile does not answer that question: it lists what could be
# reached, not what rollup kept. The bundle is what this reads. The logic
# lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop
# lives in admin/scripts/, unit-tested by `npm test`, so it runs on a laptop
# exactly as it runs here (milestone-14 deviation 24).
- name: Assert the packages bundled into web/dist are the recorded ones
working-directory: web
- name: Assert the packages bundled into admin/dist are the recorded ones
working-directory: admin
run: npm run assert-bundled
# The package and container jobs consume this bundle instead of building
@@ -164,16 +164,16 @@ jobs:
#
# A later move to v4 has to add `include-hidden-files: true` here.
# `npm run build` writes the freshness stamp to the hidden file
# web/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop
# admin/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop
# dotfiles by default. It is inactive today — v3 keeps them, and the
# package job's target path skips the stamp check regardless — but a move
# to v4 that also pointed the download back at web/dist would fail with
# "web/dist is stale".
# to v4 that also pointed the download back at admin/dist would fail with
# "admin/dist is stale".
- name: Upload the built web UI
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with:
name: web-dist
path: web/dist
name: admin-dist
path: admin/dist
if-no-files-found: error
package:
@@ -201,22 +201,22 @@ jobs:
- name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
# `dist` refuses web/dist-placeholder (ruling 4), so a real bundle has to
# `dist` refuses admin/dist-placeholder (ruling 4), so a real bundle has to
# exist before the packaging gate runs. It arrives from the frontend job,
# already formatted, linted, typechecked, tested and licence-checked.
#
# The target is deliberately not `web/dist`: build.zig runs the freshness
# The target is deliberately not `admin/dist`: build.zig runs the freshness
# stamp check for that exact path and no other (milestone-15 ruling 5),
# and the check shells out to `node`. Here it would buy nothing — the
# stamp hashes the web/ sources, not the bundle, so against a checkout of
# stamp hashes the admin/ sources, not the bundle, so against a checkout of
# the same commit that built the bundle it can only agree. An explicit
# path is the case build.zig documents for a bundle built elsewhere, and
# taking it keeps node out of this job entirely.
- name: Download the web UI built by the frontend job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: web-dist
path: web-dist-ci
name: admin-dist
path: admin-dist-ci
# Ruling 2: build.zig.zon is the only place besides the tag that carries
# the version, and ruling 5 makes verify-dist assert the two agree. The
@@ -239,7 +239,7 @@ jobs:
zig build dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web-dist-ci \
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
# verify-dist owns every assert the CI shell used to make: ELF static
@@ -255,7 +255,7 @@ jobs:
zig build verify-dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web-dist-ci \
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
# deploy/docker/Dockerfile copies both of these trees and nothing else
+4 -4
View File
@@ -269,10 +269,10 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
cache-dependency-path: admin/package-lock.json
- name: Build the web UI
working-directory: web
working-directory: admin
run: |
npm ci
npm run build
@@ -283,7 +283,7 @@ jobs:
zig build dist
-Dversion-string="$VERSION"
-Dgit-commit="$TAG_COMMIT"
-Dweb-dist=web/dist
-Dadmin-dist=admin/dist
-Doptimize=ReleaseSafe
- name: Verify the release artifacts
@@ -291,7 +291,7 @@ jobs:
zig build verify-dist
-Dversion-string="$VERSION"
-Dgit-commit="$TAG_COMMIT"
-Dweb-dist=web/dist
-Dadmin-dist=admin/dist
-Doptimize=ReleaseSafe
# Step 9. Extracted and validated before anything is pushed anywhere, so
+4 -4
View File
@@ -1,7 +1,7 @@
.zig-cache/
zig-out/
zig-pkg/
web/node_modules/
web/dist/
web/dist-sourcemap/
web-dist-ci/
admin/node_modules/
admin/dist/
admin/dist-sourcemap/
admin-dist-ci/
+3 -3
View File
@@ -34,10 +34,10 @@ One trap: running a cached test binary by hand with `--listen=-` aborts with `in
## Regenerating the contract samples
`web/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
`admin/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
```
zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts"
zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
```
then update `web/src/lib/types.ts` to match and commit both. Never edit the generated file by hand.
then update `admin/src/lib/types.ts` to match and commit both. Never edit the generated file by hand.
+47 -1
View File
@@ -4,7 +4,53 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [Unreleased]
## [0.0.7] - 2026-08-20
Operational failures get a page of their own, and the query log stops wearing out the disk it lives on: the deployed Pi was writing half a gigabyte a day to store two megabytes of query rows, one transaction per query. Both came out of running 0.0.6 on real hardware.
### Added
- **A diagnostics page.** Operational failures now land in one curated log instead of only journald: blocklist download failures, certificate reload failures, disk pressure, query-log writer and maintenance failures, upstream exchange and history failures, client tracking failures, listener and configuration problems at boot, and the query-log recreation an upgrade causes. One entry per failing subject — an entry opens on the first failure, counts repeats, and closes itself when the subject recovers; nothing needs dismissing. Each entry says what it means for the service and what to do about it. `GET /api/diagnostics` serves the log, `GET /api/health` reports the active counts and degrades while the diagnostics store itself cannot write, and `/metrics` gains `nxdns_diagnostics_active_warnings`, `nxdns_diagnostics_active_errors` and `nxdns_diagnostics_write_failures_total`. Resolved entries can be purged when you decide the history has served its purpose — one entry from its row or its detail page, or the whole resolved history at once with "Purge all resolved" (`DELETE /api/diagnostics/{id}` and `DELETE /api/diagnostics`). An entry that is still failing is the current state of the box, not history, so it has no purge action and the API answers 409.
### Changed
- **The query log commits once a minute instead of once a query.** The writer batched for 100 milliseconds, which at a household's query rate means almost every query got a transaction of its own — and a transaction costs the disk far more than the row it carries. On the deployed Pi that came to roughly 0.5 GiB of writes a day to store 2.3 MB of query rows, the kind of write volume that kills an SD card. The batch window is now `logging.query_log_flush_interval_s`: 60 seconds by default (the same minute Pi-hole's `DBinterval` defaults to, for the same reason), anything from 0 to 3600, editable on the settings page. Batches are still capped at 100 rows, so a burst is committed as soon as it fills one rather than waiting out the window, and the in-memory queue, its drop-oldest backpressure and retention are untouched. The price is two kinds of lag: a crash costs about one interval of query history — more if the writer was held back by a full disk or a slow write — and every query-log-backed view — the query-log page, the dashboard totals, the timeseries — is about one interval behind. The live page is not affected; it is fed before the queue. Set the key to `0` for the old write-immediately behavior.
### Fixed
- **Shutdown no longer races the last query rows to the disk.** The query-log writer was stopped by the same cancellation that stopped the DNS listeners, so whether the batch it was holding reached the database depended on which happened to land first, the cancellation or the queue closing. Shutdown now stops and joins the listeners and every other query producer first, then closes the queue, then waits for the writer to finish emptying it — the held batch and everything still queued get written. If free space is below the critical threshold and the disk monitor will not let that final write through, the rows are counted as dropped instead of holding the exit open indefinitely.
- **An upstream success rate no longer rounds up to 100.0% while failures stand.** One decimal place cannot hold 12,696 successes out of 12,698 attempts: it rounded to `100.0%`, so the row claimed perfect reliability next to a failure count of 2. Neither end of the scale is reachable by rounding any more — `100.0%` needs an actual absence of failures and `0.0%` an actual absence of successes, and a rate a hair off either end shows `99.9%` or `0.1%` instead.
- **A query log set aside by a schema change is no longer named `corrupt`.** Every recreate wrote the old file to `querylog.db.corrupt-<unix seconds>`, whatever sent it there — including the fingerprint mismatch an upgrade causes, where the file is a healthy database this build simply cannot read. The name is the only account of the reason that outlives the log line, so it read as an accusation and invited operators to delete an intact file. The name now says which of the four cases it hit: `querylog.db.corrupt-…`, `.not-a-database-…`, `.quick-check-failed-…` or `.schema-changed-…`. The 0.0.6 upgrade produces `schema-changed`. Nothing else about the recreate changed, and no existing aside file is renamed.
## [0.0.6] - 2026-08-17
The period picker now scopes the whole dashboard. The upstream table was the last widget that ignored it, and fixing that meant recording upstream outcomes over time instead of counting them since boot. Read the query-log note below before you upgrade.
### Added
- Four metrics for the new upstream-history recorder: `nxdns_upstream_history_flushes_total`, `nxdns_upstream_history_flush_failures_total`, `nxdns_upstream_history_rows_dropped_total` and the `nxdns_upstream_history_pending` gauge. While a flush to the database keeps failing, `GET /api/health` reports `degraded`; it recovers on the next flush that succeeds.
### Changed
- **Upstream health answers for the selected period.** The dashboard's upstream table used to print counters accumulated since process start beside a success rate taken over the last 32 exchanges, which is how "63 failures" and "100.0% success rate" ended up in the same row under a period picker that scoped nothing there. Every upstream outcome is now aggregated into its wall-clock minute and written to `querylog.db`, and `GET /api/upstream/health?period=…` serves the selected window: attempts, failures, success rate, and the last failure with its error name, all inside the period, from 31 days of history. A window with no attempts reports no success rate at all instead of a perfect one, and the table shows an em-dash. The in-memory health state that drives failover and backoff is unchanged, as are its `/metrics` series.
- **`GET /api/upstream/health` changed shape.** Gone from each upstream: `consecutive_failures`, `total_successes`, `total_failures`, the last-32 `success_rate`, `last_error` and `last_error_age_s`. Each upstream keeps `url`, `enabled` and `available` and gains a `period` object with the ranged numbers; the body gains `period`, `since`, `until` and a `complete` flag that says whether any outcome was known to be dropped inside the window. The removed counters are still exported by `/metrics` under their existing names. On the dashboard the "Right now" section is gone with them: the upstream table rejoined the ranged part of the page, and the disk card, the one live widget left, is titled "Storage now".
- **The query log is recreated on upgrade.** Recording upstream history added two tables to the `querylog.db` schema, and its fingerprint check refuses a database that does not match the shipped definition. On first start this version renames the existing `querylog.db` aside as `querylog.db.corrupt-<unix seconds>` in the data directory and creates a fresh one, so query history and stats restart empty. The renamed file is left in place rather than deleted, so removing it is your call. `config.db` is untouched: no configuration is lost.
## [0.0.5] - 2026-08-16
One rendering fix on the 0.0.4 feature, caught the day it shipped.
### Changed
- The query tables no longer repeat the *learned* tag on every row: in the live page and the query log a learned name is just muted, with the address still in the row's tooltip. The clients page keeps the tag, where it appears once per client and says something.
## [0.0.4] - 2026-08-16
The names learned in 0.0.3 now show up where queries do: the live page and the query log name each client instead of printing its address.
### Added
- **Client names in the query tables.** The live page and the query log show each query's client by name, with the same precedence as the clients page: a hand-typed name wins, else the learned name (muted, tagged *learned*), else the bare address. When a name replaces the address, the address stays readable as the row's tooltip. Devices that appear mid-stream show their address first and pick up their name within half a minute.
## [0.0.3] - 2026-08-15
+42 -4
View File
@@ -242,7 +242,7 @@ src/
rules.zig local.zig lookup.zig pause.zig settings.zig
upstream_health.zig certs.zig health.zig version.zig
web/ # Vite + React + TS + StyleX + React Aria + TanStack
admin/ # Vite + React + TS + StyleX + React Aria + TanStack
vendor/ # sqlite3 amalgamation, mbedtls (pinned)
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
tests/ # dns/ integration/ fuzz/
@@ -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. Exposed via `GET /api/upstream/health`, dashboard, `/metrics`, and `nxdns check`.
- `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.
- 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.
@@ -435,8 +435,28 @@ CREATE TABLE forward_zones (
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
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 the one table here that is **not** configuration. It is the diagnostics log of `src/storage/events.zig`: one row per failure episode, opened on the first failure and resolved when the same subject succeeds again. It is deliberately absent from `config_schema.table_names` and `config_schema.delete_order`, so `nxdns export` never emits it and `nxdns import` never wipes it.
### 11.3 querylog.db Schema
```sql
@@ -460,12 +480,30 @@ 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
- In-memory buffer, mutex guarded, hard cap `query_log_buffer_max` (default 10000).
- Flush: batch size (default 100) or max interval (default 100ms).
- Flush: batch size (100, comptime) or max interval `query_log_flush_interval_s` (default 60s, 03600, 0 = do not wait). One transaction per interval: at household query rates a per-query commit costs orders of magnitude more disk writes than the rows are worth. The interval is also roughly what a crash costs, while the writer is healthy and the disk gate is open — a gated or lock-delayed batch is older, so it is a normal case, not a bound.
- Privacy transforms (hide_domains / hide_client_ips) applied before persist + SSE fanout.
- Backpressure: buffer full → drop oldest unflushed entry, increment monotonic `queries_dropped` (exposed in `/api/health` + `/metrics`). SSE fanout precedes buffer insert, so live viewers still see dropped-from-persistence entries.
@@ -522,7 +560,7 @@ 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`
- `GET /api/upstream/health?period=…`
- `POST /api/certs/reload`
- `GET /api/health` — overall + disk + upstream + queries_dropped rollup
- `GET /metrics` — Prometheus text exposition: query counters (total/blocked/cached), per-upstream health, cache stats, queries_dropped, disk gauges
+5 -5
View File
@@ -60,20 +60,20 @@ Full install instructions, including the systemd path and the Pi 5 recipe, are i
Requires [Zig 0.16.0](https://ziglang.org/download/) and Node.js 24 (for the web UI). C dependencies (SQLite, mbedTLS) are vendored and built by `zig build`.
```sh
(cd web && npm ci && npm run build) # web UI -> web/dist
zig build -Dweb-dist=web/dist # native binary -> zig-out/bin/nxdns
(cd admin && npm ci && npm run build) # web UI -> admin/dist
zig build -Dadmin-dist=admin/dist # native binary -> zig-out/bin/nxdns
zig build test --summary all # unit tests
```
The release artifacts come out of the same build graph, so the whole release build runs on a laptop exactly as it runs on the CI runner:
```sh
(cd web && npm ci && npm run build) # required: dist refuses the placeholder
(cd admin && npm ci && npm run build) # required: dist refuses the placeholder
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/
zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe # the release checks
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # the release checks
```
The version comes from `build.zig.zon` because `verify-dist` asserts the two agree; a tag sets both.

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 303 B

View File
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "nxdns-web",
"name": "nxdns-admin",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nxdns-web",
"name": "nxdns-admin",
"version": "0.0.0",
"dependencies": {
"@stylexjs/stylex": "0.19.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "nxdns-web",
"name": "nxdns-admin",
"private": true,
"version": "0.0.0",
"type": "module",

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

@@ -1,11 +1,11 @@
#!/usr/bin/env node
// The set of npm packages whose bytes reach web/dist must be exactly the set
// The set of npm packages whose bytes reach admin/dist must be exactly the set
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
//
// The shipped build carries no sourcemaps, so this makes a second build with
// them into its own directory: the `sources` list of each chunk names the
// modules that went into it, and the artifact `npm run build` produced stays
// untouched. Runs from web/ as `npm run assert-bundled`, on a laptop exactly as
// untouched. Runs from admin/ as `npm run assert-bundled`, on a laptop exactly as
// on the runner.
import { execFileSync } from "node:child_process";
@@ -85,10 +85,10 @@ try {
const recorded = recordedPackages(identity);
if (recorded === null) {
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section");
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into admin/dist]' section");
}
if (recorded.length === 0) {
fail("assert-bundled: the '[npm packages bundled into web/dist]' section is empty");
fail("assert-bundled: the '[npm packages bundled into admin/dist]' section is empty");
}
const { added, removed } = comparePackages(recorded, bundled);
@@ -96,12 +96,12 @@ if (added.length !== 0 || removed.length !== 0) {
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
fail(
[
"the set of npm packages in web/dist has changed (-recorded +current).",
"the set of npm packages in admin/dist has changed (-recorded +current).",
"Work out what the change means for licenses/inventory.zon first, then record",
"the new list in that section of licenses/dependency-identity.txt.",
].join("\n"),
);
}
process.stdout.write(`web/dist bundles exactly the ${bundled.length} recorded packages:\n`);
process.stdout.write(`admin/dist bundles exactly the ${bundled.length} recorded packages:\n`);
for (const name of bundled) process.stdout.write(`${name}\n`);
@@ -14,7 +14,7 @@
// brace inside a string blinds the stripper; and with several stylesheets it
// judges each alone, not their load order in the document.
//
// This check runs from web/ as part of `npm run build`.
// This check runs from admin/ as part of `npm run build`.
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
@@ -110,7 +110,7 @@ for (const sheet of sheets) {
}
if (failed) {
console.error("Wrap it in a layer declared before StyleX's, as web/src/styles.css does.");
console.error("Wrap it in a layer declared before StyleX's, as admin/src/styles.css does.");
process.exit(1);
}
console.log(
@@ -8,7 +8,7 @@
// lockfile, no version and no dependency set changes — only the bundle does. So
// the bundle is what this reads.
const sectionHeading = "[npm packages bundled into web/dist]";
const sectionHeading = "[npm packages bundled into admin/dist]";
// A sourcemap `sources` entry for a dependency ends in
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
@@ -48,7 +48,7 @@ describe("recordedPackages", () => {
"[some earlier section]",
"ignored",
"",
"[npm packages bundled into web/dist]",
"[npm packages bundled into admin/dist]",
"react",
"@tanstack/react-query",
"",
@@ -64,7 +64,7 @@ describe("recordedPackages", () => {
it("distinguishes a missing section from an empty one", () => {
expect(recordedPackages("[other]\nx\n")).toBeNull();
expect(recordedPackages("[npm packages bundled into web/dist]\n\n[next]\n")).toEqual([]);
expect(recordedPackages("[npm packages bundled into admin/dist]\n\n[next]\n")).toEqual([]);
});
});
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// Freshness stamp for web/dist (milestone-15 ruling 5). A stale dist has
// already shipped a crashing settings page once. Write mode runs from web/ as
// Freshness stamp for admin/dist (milestone-15 ruling 5). A stale dist has
// already shipped a crashing settings page once. Write mode runs from admin/ as
// part of `npm run build`; check mode runs from the repository root as a
// build.zig system command. Every path resolves from this file's own location
// so both working directories hash the same set.
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const distDir = join(webRoot, "dist");
const stampFile = join(distDir, ".src-hash");
const stampRelative = "web/dist/.src-hash";
const stampRelative = "admin/dist/.src-hash";
const inputDirs = ["src", "public"];
const inputFiles = [
@@ -26,7 +26,7 @@ const inputFiles = [
"tsconfig.node.json",
];
const staleMessage = "web/dist is stale: rebuild the frontend (npm run build)";
const staleMessage = "admin/dist is stale: rebuild the frontend (npm run build)";
function fail(message) {
process.stderr.write(`${message}\n`);
@@ -39,7 +39,7 @@ function walk(relativeDir) {
try {
entries = readdirSync(absolute, { withFileTypes: true });
} catch (err) {
fail(`stamp-dist: cannot read web/${relativeDir}: ${err.message}`);
fail(`stamp-dist: cannot read admin/${relativeDir}: ${err.message}`);
}
const found = [];
for (const entry of entries) {
@@ -57,9 +57,9 @@ function inputSet() {
const paths = [...inputFiles, ...inputDirs.flatMap(walk)];
for (const path of inputFiles) {
try {
if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: web/${path} is not a file`);
if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: admin/${path} is not a file`);
} catch (err) {
fail(`stamp-dist: cannot stat web/${path}: ${err.message}`);
fail(`stamp-dist: cannot stat admin/${path}: ${err.message}`);
}
}
// Sorted by path so the digest does not depend on directory order.
@@ -56,23 +56,6 @@ const styles = stylex.create({
dash: {
color: colors.textMuted,
},
/**
* A learned name is runtime state, not something the operator typed, so it
* reads muted and carries an outlined "learned" tag. The tag is real text
* a screen reader announces it because colour alone is not an affordance.
*/
learnedTag: {
marginLeft: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
borderRadius: "0.25rem",
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
@@ -153,9 +136,9 @@ export default function ClientsPage() {
{client.name !== "" ? (
client.name
) : client.learned_name !== "" ? (
<span {...stylex.props(styles.dash)}>
<span {...stylex.props(shared.learnedName)}>
{client.learned_name}
<span {...stylex.props(styles.learnedTag)}>learned</span>
<span {...stylex.props(shared.learnedTag)}>learned</span>
</span>
) : (
<span {...stylex.props(styles.dash)}></span>
@@ -0,0 +1,53 @@
/**
* The client column of the query tables reads as a name wherever one is known,
* with the same precedence the Clients page applies: a hand-typed `name` wins,
* the reverse-DNS `learned_name` stands in muted behind it, and an address with
* neither — including one the loaded list has never seen — stays bare.
*
* The muted colour is the whole of the affordance here. The Clients page pairs
* it with an outlined "learned" tag, and keeps it: one mention per client is
* information. Repeating that tag down every row of a query table is noise, so
* the tables carry the name alone.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientsQuery } from "@/lib/queries";
import type { Client } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
/**
* The live stream names clients the loaded list has never seen. Polling folds
* them in on the next tick, which keeps the lookup a single cached query
* instead of a fetch fired per unknown address.
*/
const CLIENTS_POLL_MS = 30_000;
export function useClientNames(): ClientNames {
const { data } = useQuery({ ...clientsQuery(), refetchInterval: CLIENTS_POLL_MS });
return useMemo(
() =>
new Map(
(data ?? []).map((client) => [client.ip, { name: client.name, learned_name: client.learned_name }]),
),
[data],
);
}
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
const client = names.get(ip);
if (client === undefined || (client.name === "" && client.learned_name === "")) {
return <span {...stylex.props(shared.mono)}>{ip}</span>;
}
// The name replaces the address on screen, so the address stays reachable
// as the tooltip rather than disappearing from the row entirely.
if (client.name !== "") return <span title={ip}>{client.name}</span>;
return (
<span title={ip} {...stylex.props(shared.learnedName)}>
{client.learned_name}
</span>
);
}
@@ -5,6 +5,9 @@ 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",
@@ -58,32 +61,67 @@ const RESPONSES: Record<string, unknown> = {
writer_failed: false,
refreshes_gated: 0,
snapshot_generation: 3,
diagnostics: { state: "recording", active_warnings: 1, active_errors: 0 },
},
"/api/upstream/health": {
"/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,
consecutive_failures: 4,
total_successes: 90,
total_failures: 10,
success_rate: 0.9,
last_error: "timeout",
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,
consecutive_failures: 0,
total_successes: 100,
total_failures: 0,
success_rate: 1,
last_error: "",
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: 2,
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 },
};
@@ -142,7 +180,7 @@ test("dashboard renders stats, chart, disk card, upstream table and health banne
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
expect(screen.getByText("Disk")).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();
@@ -155,10 +193,32 @@ test("dashboard renders stats, chart, disk card, upstream table and health banne
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
expect(screen.getByText("90.0%")).toBeTruthy();
expect(screen.getByText("100.0%")).toBeTruthy();
expect(screen.getByText("timeout")).toBeTruthy();
expect(screen.getByText("timeout · 3h ago")).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" });
@@ -173,7 +233,7 @@ test("period picker refetches stats and shows the empty chart state", async () =
});
test("one failing endpoint degrades its own widget on cold navigation", async () => {
failing.add("/api/upstream/health");
failing.add("/api/upstream/health?period=24h");
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
@@ -184,6 +244,6 @@ test("one failing endpoint degrades its own widget on cold navigation", async ()
expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Disk")).toBeTruthy();
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
});
@@ -66,7 +66,7 @@ const styles = stylex.create({
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
},
},
/** The chart takes two thirds beside the disk card from `lg`, one column below. */
/** The chart takes two thirds beside the storage card from `lg`, one column below. */
panelGrid: {
display: "grid",
gap: "1rem",
@@ -123,7 +123,7 @@ export default function DashboardPage() {
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
const health = useQuery(healthQuery());
const upstreamHealth = useQuery(upstreamHealthQuery());
const upstreamHealth = useQuery({ ...upstreamHealthQuery(period), placeholderData: keepPreviousData });
return (
<section {...stylex.props(styles.page)}>
@@ -74,8 +74,10 @@ function stateStyle(state: Health["disk"]["state"]) {
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)}>
Disk
Storage now
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
</h2>
<dl {...stylex.props(styles.list)}>
@@ -0,0 +1,180 @@
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;
beforeEach(() => {
vi.spyOn(Date, "now").mockReturnValue(NOW_S * 1000);
});
afterEach(() => {
vi.restoreAllMocks();
});
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,
// 3h30m ago, far from a unit boundary.
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", "Last failure"]) {
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("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("last failure pairs the error name with its age, em-dash when the window holds none", () => {
renderTable([
entry({ url: "https://a.example/dns-query" }),
entry({
url: "https://b.example/dns-query",
period: period({ last_failure_at: null, last_failure_error: null }),
}),
]);
expect(within(rowOf("https://a.example/dns-query")).getByText("Timeout · 3h ago")).toBeTruthy();
expect(within(rowOf("https://b.example/dns-query")).getByText("—")).toBeTruthy();
});
test("a window with no attempts renders em-dashes 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();
});
@@ -0,0 +1,241 @@
import * as stylex from "@stylexjs/stylex";
import { formatAge } from "@/lib/format";
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 age is formatted once, when the row renders; nothing here ticks. It is
* measured against the browser's clock rather than the response's `until`, so a
* cached response ages visibly instead of freezing at the moment it was served.
*/
function lastFailure(period: UpstreamPeriodStats, nowSeconds: number): string {
if (period.last_failure_at === null) return "—";
const age = formatAge(Math.max(0, nowSeconds - period.last_failure_at));
const error = period.last_failure_error;
return error === null || error === "" ? age : `${error} · ${age}`;
}
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
const nowSeconds = Math.floor(Date.now() / 1000);
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={4} {...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>
<th scope="col" {...stylex.props(styles.th)}>
Last failure
</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>
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
{lastFailure(upstream.period, nowSeconds)}
</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>
);
}
@@ -0,0 +1,218 @@
import { cleanup, 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 { DIAGNOSTIC_CODES, type DiagnosticEvent } from "@/lib/types";
import { EVENT_COPY } from "./eventCopy";
const NOW_S = Math.floor(Date.now() / 1000);
function event(overrides: Partial<DiagnosticEvent> = {}): DiagnosticEvent {
return {
id: 42,
code: "blocklist.refresh",
component: "blocklist",
subject: "StevenBlack",
severity: "warning",
first_seen: NOW_S - 7200,
last_seen: NOW_S - 600,
occurrences: 4,
resolved_at: null,
detail: "download failed: ConnectionTimedOut",
...overrides,
};
}
/** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */
const NO_CONTENT = Symbol("204");
let responses: Record<string, unknown>;
let requested: string[];
beforeEach(() => {
requested = [];
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
const url = String(input);
const method = init?.method ?? "GET";
const key = method === "GET" ? url : `${method} ${url}`;
requested.push(key);
const payload = responses[key];
if (payload === undefined)
return new Response(JSON.stringify({ error: "no such event" }), {
status: 404,
headers: { "content-type": "application/json" },
});
if (payload === NO_CONTENT) return new Response(null, { status: 204 });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/**
* `retry` is off in the failure test: the shared client backs 5xx off for
* seconds, which the render assertions would sit through for nothing.
*/
function renderDetail(id: number, { retry = true } = {}) {
const queryClient = createQueryClient();
if (!retry) {
const defaults = queryClient.getDefaultOptions();
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
}
const router = createAppRouter(createMemoryHistory({ initialEntries: [`/diagnostics/${id}`] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
test("an open episode shows its facts, its copy and the error the server sent", async () => {
responses["/api/diagnostics/42"] = event();
renderDetail(42);
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
expect(screen.getByText("Warning")).toBeTruthy();
expect(screen.getByText("StevenBlack")).toBeTruthy();
expect(screen.getByText("Active for 2h")).toBeTruthy();
expect(screen.getByText("Not yet — still failing")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy();
expect(screen.getByText("blocklist.refresh")).toBeTruthy();
expect(screen.getByText(EVENT_COPY["blocklist.refresh"].impact)).toBeTruthy();
expect(screen.getByText(EVENT_COPY["blocklist.refresh"].remediation)).toBeTruthy();
expect(screen.getByText("download failed: ConnectionTimedOut")).toBeTruthy();
expect(screen.getByRole("link", { name: "Go to Blocklists" }).getAttribute("href")).toBe("/blocklists");
});
test("a resolved episode states how long it lasted, not how long it has run", async () => {
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
renderDetail(7);
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
expect(screen.getByText("Resolved after 1h")).toBeTruthy();
expect(screen.queryByText("Not yet — still failing")).toBeNull();
});
test("an open episode offers no purge", async () => {
responses["/api/diagnostics/42"] = event();
renderDetail(42);
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
expect(screen.queryByRole("button", { name: "Purge" })).toBeNull();
});
test("purging a resolved episode asks first, then returns to the list", async () => {
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
responses["DELETE /api/diagnostics/7"] = NO_CONTENT;
responses["/api/diagnostics?state=active"] = { events: [], next_before: null, active: { warnings: 0, errors: 0 } };
responses["/api/diagnostics?state=resolved"] = {
events: [],
next_before: null,
active: { warnings: 0, errors: 0 },
};
const router = renderDetail(7);
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good.");
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(requested).not.toContain("DELETE /api/diagnostics/7");
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/7"));
// The row it was showing no longer exists, so the page it navigates to is
// the list rather than a 404 of its own.
await waitFor(() => expect(router.state.location.pathname).toBe("/diagnostics"));
});
test("a refused purge stays on the event and shows why", async () => {
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
if (init?.method === "DELETE")
return new Response(JSON.stringify({ error: "the event is still active" }), {
status: 409,
headers: { "content-type": "application/json" },
});
return new Response(JSON.stringify(responses[String(input)] ?? {}), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
const router = renderDetail(7, { retry: false });
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("the event is still active");
expect(router.state.location.pathname).toBe("/diagnostics/7");
});
test("every code renders its own title, impact and remediation", async () => {
for (const [index, code] of DIAGNOSTIC_CODES.entries()) {
const id = 100 + index;
responses[`/api/diagnostics/${id}`] = event({ id, code, component: code.slice(0, code.indexOf(".")) });
renderDetail(id);
const copy = EVENT_COPY[code];
await screen.findByRole("heading", { name: copy.title });
expect(screen.getByText(copy.impact), code).toBeTruthy();
expect(screen.getByText(copy.remediation), code).toBeTruthy();
screen.getByText(code);
cleanup();
}
});
test("an event retention has removed shows the server's message, not an empty page", async () => {
renderDetail(999);
await screen.findByText("no such event");
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
});
test("an unavailable store reports the failure instead of loading forever", async () => {
responses["/api/diagnostics/42"] = event();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) =>
String(input).startsWith("/api/diagnostics/")
? new Response(JSON.stringify({ error: "store unavailable" }), {
status: 503,
headers: { "content-type": "application/json" },
})
: new Response(JSON.stringify(responses[String(input)] ?? {}), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
renderDetail(42, { retry: false });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("The server is starting or degraded.");
expect(screen.queryByText("Loading event…")).toBeNull();
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
});
@@ -0,0 +1,226 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { formatDuration, formatTime } from "@/lib/format";
import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import SeverityBadge from "./SeverityBadge";
import { componentLabel, copyFor } from "./eventCopy";
const styles = stylex.create({
back: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
headingRow: {
marginTop: "0.5rem",
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: "0.5rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
purgeAction: {
marginInlineStart: "auto",
},
subject: {
marginTop: "0.25rem",
color: colors.textSecondary,
wordBreak: "break-all",
},
panel: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: "1rem",
},
facts: {
display: "grid",
gap: "0.5rem 1rem",
gridTemplateColumns: {
default: "auto",
"@media (min-width: 640px)": "max-content 1fr",
},
margin: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
term: {
color: colors.textMuted,
},
value: {
margin: 0,
},
sectionHeading: {
marginTop: "1.5rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
prose: {
marginTop: "0.5rem",
maxWidth: "48rem",
fontSize: "0.875rem",
lineHeight: "1.5rem",
},
detail: {
marginTop: "0.5rem",
maxWidth: "48rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "0.75rem",
fontSize: "0.8125rem",
lineHeight: "1.25rem",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
},
links: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
link: {
color: colors.primaryOnSurface,
},
loading: {
marginTop: "1rem",
color: colors.textMuted,
},
});
export default function DiagnosticDetailPage() {
const { id } = useParams({ from: "/shell/diagnostics/$id" });
const eventId = Number(id);
const { data, error, isPending, refetch } = useQuery(diagnosticQuery(eventId));
const navigate = useNavigate();
const queryClient = useQueryClient();
const purge = useMutation(diagnosticPurgeMutation(queryClient));
const [confirming, setConfirming] = useState(false);
function confirmPurge() {
setConfirming(false);
// The row this page is about is gone, so staying here would show the
// 404 the purge itself caused.
purge.mutate(eventId, { onSuccess: () => void navigate({ to: "/diagnostics" }) });
}
if (isPending) {
return (
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
Loading event
</p>
);
}
if (data === undefined) {
return (
<section>
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
All diagnostics
</Link>
<InlineError error={error} onRetry={() => void refetch()} />
</section>
);
}
const copy = copyFor(data.code);
const resolvedAt = data.resolved_at;
const span = (resolvedAt ?? Math.floor(Date.now() / 1000)) - data.first_seen;
return (
<section>
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
All diagnostics
</Link>
<div {...stylex.props(styles.headingRow)}>
<h1 {...stylex.props(styles.heading)}>{copy.title}</h1>
<SeverityBadge severity={data.severity} />
{/* Only history can be purged: an open episode is the current state of the box. */}
{resolvedAt !== null && (
<button
type="button"
onClick={() => setConfirming(true)}
disabled={purge.isPending}
{...stylex.props(styles.purgeAction, shared.dangerLinkButton, shared.focusRing)}
>
Purge
</button>
)}
</div>
<p {...stylex.props(styles.subject)}>{data.subject}</p>
<InlineError error={purge.error} />
<div {...stylex.props(styles.panel)}>
<dl {...stylex.props(styles.facts)}>
<dt {...stylex.props(styles.term)}>State</dt>
<dd {...stylex.props(styles.value)}>
{resolvedAt === null
? `Active for ${formatDuration(span)}`
: `Resolved after ${formatDuration(span)}`}
</dd>
<dt {...stylex.props(styles.term)}>First seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(data.first_seen)}</dd>
<dt {...stylex.props(styles.term)}>Last seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(data.last_seen)}</dd>
<dt {...stylex.props(styles.term)}>Occurrences</dt>
<dd {...stylex.props(styles.value, shared.tabularNums)}>{data.occurrences}</dd>
<dt {...stylex.props(styles.term)}>Resolved</dt>
<dd {...stylex.props(styles.value)}>
{data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)}
</dd>
<dt {...stylex.props(styles.term)}>Component</dt>
<dd {...stylex.props(styles.value)}>{componentLabel(data.component)}</dd>
<dt {...stylex.props(styles.term)}>Code</dt>
<dd {...stylex.props(styles.value, shared.mono)}>{data.code}</dd>
</dl>
</div>
<h2 {...stylex.props(styles.sectionHeading)}>Impact</h2>
<p {...stylex.props(styles.prose)}>{copy.impact}</p>
<h2 {...stylex.props(styles.sectionHeading)}>What to do</h2>
<p {...stylex.props(styles.prose)}>{copy.remediation}</p>
<h2 {...stylex.props(styles.sectionHeading)}>Last error</h2>
{data.detail === "" ? (
<p {...stylex.props(styles.prose)}>The server recorded no error text for this event.</p>
) : (
<pre {...stylex.props(styles.detail, shared.mono)}>{data.detail}</pre>
)}
{copy.link !== undefined && (
<p {...stylex.props(styles.links)}>
<Link to={copy.link.to} {...stylex.props(styles.link, shared.focusRing)}>
Go to {copy.link.label}
</Link>
</p>
)}
<ConfirmDialog
isOpen={confirming}
title="Purge event"
message="Purge this resolved event? Its history is gone for good."
confirmLabel="Purge"
onConfirm={confirmPurge}
onCancel={() => setConfirming(false)}
/>
</section>
);
}
@@ -0,0 +1,339 @@
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 type { DiagnosticEvent, DiagnosticsPage } from "@/lib/types";
// Ages are rendered against the wall clock, so the fixtures are anchored to it
// rather than to a frozen instant: faking time here would fight the query
// client's own timers for no gain.
const NOW_S = Math.floor(Date.now() / 1000);
function event(id: number, overrides: Partial<DiagnosticEvent> = {}): DiagnosticEvent {
return {
id,
code: "blocklist.refresh",
component: "blocklist",
subject: "StevenBlack",
severity: "warning",
first_seen: NOW_S - 3600,
last_seen: NOW_S - 300,
occurrences: 3,
resolved_at: null,
detail: "download failed: ConnectionTimedOut",
...overrides,
};
}
function page(events: DiagnosticEvent[], nextBefore: number | null = null): DiagnosticsPage {
return { events, next_before: nextBefore, active: { warnings: 1, errors: 1 } };
}
const ACTIVE = page([
event(42),
event(41, {
code: "upstream.exchange",
component: "upstream",
subject: "tls://dns.example:853",
severity: "error",
occurrences: 1,
}),
]);
const RESOLVED = page([
event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
]);
/** A stubbed response that carries a non-200 status instead of a payload. */
class Failure {
constructor(
readonly status: number,
readonly body: unknown,
) {}
}
function fail(status: number, message: string): Failure {
return new Failure(status, { error: message });
}
/** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */
const NO_CONTENT = Symbol("204");
let responses: Record<string, unknown>;
let requested: string[];
beforeEach(() => {
requested = [];
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
"/api/diagnostics?state=active": ACTIVE,
"/api/diagnostics?state=resolved": RESOLVED,
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
const url = String(input);
const method = init?.method ?? "GET";
// Reads stay keyed by url alone, so the assertions below read as the
// request line they are; writes carry their method.
const key = method === "GET" ? url : `${method} ${url}`;
requested.push(key);
const payload = responses[key];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
if (payload === NO_CONTENT) return new Response(null, { status: 204 });
if (payload instanceof Failure) {
return new Response(JSON.stringify(payload.body), {
status: payload.status,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/**
* `retry` is off in the failure tests: the shared client backs 5xx off for
* seconds, which the render assertions would sit through for nothing.
*/
function renderRoute(path = "/diagnostics", { retry = true } = {}) {
const queryClient = createQueryClient();
if (!retry) {
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;
}
/** A RAC Select names its trigger with the current value and then the label. */
function trigger(label: string): HTMLElement {
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}
async function pick(label: string, option: string) {
fireEvent.click(trigger(label));
fireEvent.click(await screen.findByRole("option", { name: option }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
}
test("active episodes come first, each with its title, subject, age and count", async () => {
renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
const active = screen.getByText("Blocklist source failed to update").closest("li")!;
expect(within(active).getByText("Warning")).toBeTruthy();
expect(within(active).getByText("StevenBlack")).toBeTruthy();
expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy();
const failing = screen.getByText("Upstream failing").closest("li")!;
expect(within(failing).getByText("Error")).toBeTruthy();
expect(within(failing).getByText(/1 occurrence(?!s)/)).toBeTruthy();
// The resolved history is a separate section, below the active list.
const table = within(screen.getByRole("table"));
expect(table.getByText("Disk space low")).toBeTruthy();
expect(screen.getByText(/Showing 1 resolved entry — end of history/)).toBeTruthy();
});
test("nothing open reads as good news, not as a broken page", async () => {
responses["/api/diagnostics?state=active"] = page([]);
renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
const healthy = await screen.findByText("No active operational issues.");
expect(healthy.getAttribute("role")).toBe("status");
// Quiet: no alert anywhere on the page, and no empty table standing in.
expect(screen.queryByRole("alert")).toBeNull();
});
test("a filter lands in the url and refetches both sections through it", async () => {
responses["/api/diagnostics?severity=error&state=active"] = page([
event(41, { code: "upstream.exchange", component: "upstream", severity: "error" }),
]);
responses["/api/diagnostics?severity=error&state=resolved"] = page([]);
const router = renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
await pick("Severity", "Errors");
await waitFor(() => expect(router.state.location.search).toEqual({ severity: "error" }));
await waitFor(() => expect(screen.queryByText("Blocklist source failed to update")).toBeNull());
expect(requested).toContain("/api/diagnostics?severity=error&state=active");
expect(requested).toContain("/api/diagnostics?severity=error&state=resolved");
});
test("the state filter hides the section it excludes", async () => {
const router = renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
await pick("Show", "Active only");
await waitFor(() => expect(router.state.location.search).toEqual({ state: "active" }));
expect(screen.queryByRole("heading", { name: "Resolved" })).toBeNull();
expect(screen.getByRole("heading", { name: "Active" })).toBeTruthy();
});
test("a url written by hand starts on the filters it names", async () => {
responses["/api/diagnostics?component=disk&state=resolved"] = RESOLVED;
renderRoute("/diagnostics?state=resolved&component=disk");
await screen.findByRole("heading", { name: "Diagnostics" });
await screen.findByText("Disk space low");
expect(screen.queryByRole("heading", { name: "Active" })).toBeNull();
expect(requested).toContain("/api/diagnostics?component=disk&state=resolved");
});
test("load more appends the next page of resolved history", async () => {
responses["/api/diagnostics?state=resolved"] = page(
[event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 })],
30,
);
responses["/api/diagnostics?state=resolved&before=30"] = page([
event(12, {
code: "certificate.reload",
component: "certificate",
subject: "doh",
resolved_at: NOW_S - 90_000,
}),
]);
renderRoute();
await screen.findByText("Disk space low");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("TLS certificate reload failed");
expect(screen.getByText(/Showing 2 resolved entries — end of history/)).toBeTruthy();
});
test("an unavailable store reports the failure instead of loading forever", async () => {
responses["/api/diagnostics?state=active"] = fail(503, "store unavailable");
renderRoute("/diagnostics", { retry: false });
await screen.findByRole("heading", { name: "Diagnostics" });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("The server is starting or degraded.");
expect(screen.queryByText("Loading diagnostics…")).toBeNull();
// The resolved section answered, so it still renders its own history.
expect(screen.getByText("Disk space low")).toBeTruthy();
});
test("a failed history query reports the failure and retries on demand", async () => {
responses["/api/diagnostics?state=resolved"] = fail(500, "diagnostics store read failed");
renderRoute("/diagnostics", { retry: false });
await screen.findByRole("heading", { name: "Diagnostics" });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("diagnostics store read failed");
expect(screen.queryByText("Loading history…")).toBeNull();
responses["/api/diagnostics?state=resolved"] = RESOLVED;
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await screen.findByText("Disk space low");
expect(screen.queryByRole("alert")).toBeNull();
});
test("only the resolved history offers a purge", async () => {
renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
// 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")!;
expect(within(active).queryByRole("button", { name: "Purge" })).toBeNull();
const row = screen.getByText("Disk space low").closest("tr")!;
expect(within(row).getByRole("button", { name: "Purge" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Purge all resolved" })).toBeTruthy();
});
test("with no resolved history there is nothing to purge in bulk", async () => {
responses["/api/diagnostics?state=resolved"] = page([]);
renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
await screen.findByText("Nothing has failed and recovered in the retained window.");
expect(screen.queryByRole("button", { name: "Purge all resolved" })).toBeNull();
});
test("purging one row asks first, then sends the DELETE and refetches the lists", async () => {
responses["DELETE /api/diagnostics/30"] = NO_CONTENT;
renderRoute();
await screen.findByText("Disk space low");
fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good.");
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(requested).not.toContain("DELETE /api/diagnostics/30");
fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/30"));
// The invalidation covers both sections: the page the row left and the
// active list, whose `active` counts come from the same table.
await waitFor(() =>
expect(requested.filter((url) => url === "/api/diagnostics?state=resolved").length).toBeGreaterThan(1),
);
await waitFor(() =>
expect(requested.filter((url) => url === "/api/diagnostics?state=active").length).toBeGreaterThan(1),
);
});
test("purging the whole history asks first and sends one DELETE", async () => {
responses["DELETE /api/diagnostics"] = { purged: 1 };
renderRoute();
await screen.findByText("Disk space low");
fireEvent.click(screen.getByRole("button", { name: "Purge all resolved" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain("Purge all resolved events? Active events are kept.");
// What the server will answer once the purge has landed; the refetch the
// mutation triggers is what has to pick it up.
responses["/api/diagnostics?state=resolved"] = page([]);
fireEvent.click(within(dialog).getByRole("button", { name: "Purge all" }));
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics"));
await waitFor(() => expect(screen.queryByText("Disk space low")).toBeNull());
expect(screen.getByText("Blocklist source failed to update")).toBeTruthy();
});
test("a refused purge reports the server's reason and keeps the row", async () => {
responses["DELETE /api/diagnostics/30"] = fail(409, "the event is still active; it can be purged once it resolves");
renderRoute("/diagnostics", { retry: false });
await screen.findByText("Disk space low");
fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("the event is still active");
expect(screen.getByText("Disk space low")).toBeTruthy();
});
test("an episode links to its own detail page", async () => {
responses["/api/diagnostics/42"] = event(42);
renderRoute();
const link = await screen.findByRole("link", { name: "Blocklist source failed to update" });
expect(link.getAttribute("href")).toBe("/diagnostics/42");
});
@@ -0,0 +1,498 @@
import { useState } from "react";
import {
useInfiniteQuery,
useMutation,
useQueryClient,
type InfiniteData,
type UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api";
import InlineError from "@/lib/InlineError";
import { formatDuration, formatTime } from "@/lib/format";
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
import type {
DiagnosticEvent,
DiagnosticSeverity,
DiagnosticState,
DiagnosticsFilter,
DiagnosticsPage as Page,
} from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import SeverityBadge from "./SeverityBadge";
import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy";
const DARK = "@media (prefers-color-scheme: dark)";
const STATE_OPTIONS = [
{ value: "all", label: "Active and resolved" },
{ value: "active", label: "Active only" },
{ value: "resolved", label: "Resolved only" },
];
const SEVERITY_OPTIONS = [
{ value: "any", label: "Any severity" },
{ value: "warning", label: "Warnings" },
{ value: "error", label: "Errors" },
];
const COMPONENT_OPTIONS = [
{ value: "any", label: "All components" },
...DIAGNOSTIC_COMPONENTS.map((component) => ({ value: component, label: componentLabel(component) })),
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
maxWidth: "48rem",
},
filterGrid: {
marginTop: "1rem",
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
},
maxWidth: "48rem",
},
sectionHeading: {
marginTop: "1.5rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
sectionHeadingRow: {
display: "flex",
alignItems: "baseline",
flexWrap: "wrap",
justifyContent: "space-between",
gap: "0.75rem",
},
/**
* Nothing open is the normal state of a working install, so it gets one
* quiet muted line no border, no icon, no alert role. A panel here would
* read as a broken page rather than as good news.
*/
healthy: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textSecondary,
},
empty: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
cardList: {
marginTop: "0.75rem",
display: "flex",
flexDirection: "column",
gap: "0.5rem",
listStyleType: "none",
padding: 0,
},
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "0.75rem",
paddingBlock: "0.625rem",
},
cardTop: {
display: "flex",
alignItems: "baseline",
flexWrap: "wrap",
gap: "0.5rem",
},
cardTitle: {
fontWeight: 500,
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
subject: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textSecondary,
wordBreak: "break-all",
},
meta: {
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "0.75rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
table: {
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
head: {
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
textAlign: "left",
},
th: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
color: colors.textSecondary,
whiteSpace: "nowrap",
},
row: {
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
nowrap: {
whiteSpace: "nowrap",
},
rowLink: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
footer: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
note: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
moreError: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
});
type Section = UseInfiniteQueryResult<InfiniteData<Page, unknown>, Error>;
/** The two enum filters, narrowed from the picker's string rather than cast. */
function asState(value: string): DiagnosticState | undefined {
return value === "active" || value === "resolved" ? value : undefined;
}
function asSeverity(value: string): DiagnosticSeverity | undefined {
return value === "warning" || value === "error" ? value : undefined;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function occurrenceText(count: number): string {
return `${count} ${count === 1 ? "occurrence" : "occurrences"}`;
}
function rowsOf(section: Section): DiagnosticEvent[] {
return (section.data?.pages ?? []).flatMap((page) => page.events);
}
/**
* The cursor comes from the newest page on screen, not from `hasNextPage`:
* while placeholder data stands in for a filter change the query state is
* empty, and the button would flash away and back.
*/
function hasMore(section: Section): boolean {
const pages = section.data?.pages ?? [];
const last = pages[pages.length - 1];
return last !== undefined && last.next_before !== null;
}
function MoreButton({ section }: { section: Section }) {
const more = hasMore(section);
// A 401 is already redirecting via the cache-level handleUnauthorized.
const isUnauthorized = section.error instanceof api.ApiError && section.error.status === 401;
const failed = section.isFetchNextPageError && !isUnauthorized ? errorMessage(section.error) : null;
if (!more && failed === null) return null;
return (
<>
<div {...stylex.props(styles.footer)}>
{more && (
<button
type="button"
onClick={() => {
if (section.isFetchingNextPage || section.isPlaceholderData) return;
void section.fetchNextPage();
}}
disabled={section.isFetchingNextPage || section.isPlaceholderData}
{...stylex.props(shared.button, shared.focusRing)}
>
{section.isFetchingNextPage ? "Loading…" : "Load more"}
</button>
)}
</div>
{failed !== null && (
<p role="alert" {...stylex.props(styles.moreError)}>
Failed to load more: {failed}
</p>
)}
</>
);
}
function ActiveCard({ event, now }: { event: DiagnosticEvent; now: number }) {
const copy = copyFor(event.code);
return (
<li {...stylex.props(styles.card)}>
<div {...stylex.props(styles.cardTop)}>
<SeverityBadge severity={event.severity} />
<Link
to="/diagnostics/$id"
params={{ id: String(event.id) }}
{...stylex.props(styles.cardTitle, shared.focusRing)}
>
{copy.title}
</Link>
<span {...stylex.props(styles.subject)}>{event.subject}</span>
</div>
<p {...stylex.props(styles.meta)}>
Active for {formatDuration(now - event.first_seen)} · {occurrenceText(event.occurrences)} · last failure{" "}
{formatTime(event.last_seen)}
</p>
</li>
);
}
function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge: () => void; busy: boolean }) {
const copy = copyFor(event.code);
return (
<tr {...stylex.props(styles.row)}>
<td {...stylex.props(styles.cell)}>
<SeverityBadge severity={event.severity} />
</td>
<td {...stylex.props(styles.cell)}>
<Link
to="/diagnostics/$id"
params={{ id: String(event.id) }}
{...stylex.props(styles.rowLink, shared.focusRing)}
>
{copy.title}
</Link>
</td>
<td {...stylex.props(styles.cell)}>{event.subject}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{formatTime(event.first_seen)}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
<button
type="button"
onClick={onPurge}
disabled={busy}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Purge
</button>
</td>
</tr>
);
}
export default function DiagnosticsPage() {
const search = useSearch({ from: "/shell/diagnostics" });
const navigate = useNavigate({ from: "/diagnostics" });
const state = search.state ?? "all";
const base: DiagnosticsFilter = {};
if (search.severity !== undefined) base.severity = search.severity;
if (search.component !== undefined) base.component = search.component;
const active = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "active" }, state !== "resolved"));
const history = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "resolved" }, state !== "active"));
const queryClient = useQueryClient();
const purgeOne = useMutation(diagnosticPurgeMutation(queryClient));
const purgeAll = useMutation(diagnosticsPurgeResolvedMutation(queryClient));
// `null` is "no dialog"; the id is which row it is about, and `"all"` the
// whole history. One piece of state, so the two dialogs cannot both be open.
const [pendingPurge, setPendingPurge] = useState<number | "all" | null>(null);
const activeRows = rowsOf(active);
const historyRows = rowsOf(history);
const now = Math.floor(Date.now() / 1000);
const purging = purgeOne.isPending || purgeAll.isPending;
function setSearch(patch: Partial<typeof search>) {
void navigate({ search: (prev) => ({ ...prev, ...patch }) });
}
function confirmPurge() {
if (pendingPurge === null) return;
if (pendingPurge === "all") {
purgeAll.mutate();
} else {
purgeOne.mutate(pendingPurge);
}
setPendingPurge(null);
}
return (
<section>
<h1 {...stylex.props(styles.heading)}>Diagnostics</h1>
<p {...stylex.props(styles.intro)}>
Operational failures, one entry per subject that failed. An entry opens on the first failure, counts
repeats, and closes when the subject recovers.
</p>
<div {...stylex.props(styles.filterGrid)}>
<Select
variant="compactField"
label="Show"
value={state}
onChange={(value) => setSearch({ state: asState(value) })}
options={STATE_OPTIONS}
/>
<Select
variant="compactField"
label="Severity"
value={search.severity ?? "any"}
onChange={(value) => setSearch({ severity: asSeverity(value) })}
options={SEVERITY_OPTIONS}
/>
<Select
variant="compactField"
label="Component"
value={search.component ?? "any"}
onChange={(value) => setSearch({ component: value === "any" ? undefined : value })}
options={COMPONENT_OPTIONS}
/>
</div>
{state !== "resolved" && (
<>
<h2 {...stylex.props(styles.sectionHeading)}>Active</h2>
{active.status === "error" ? (
<InlineError error={active.error} onRetry={() => void active.refetch()} />
) : active.data === undefined ? (
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
Loading diagnostics
</p>
) : activeRows.length === 0 ? (
<p {...stylex.props(styles.healthy)} role="status">
No active operational issues.
</p>
) : (
<>
<ul {...stylex.props(styles.cardList)}>
{activeRows.map((event) => (
<ActiveCard key={event.id} event={event} now={now} />
))}
</ul>
<MoreButton section={active} />
</>
)}
</>
)}
{state !== "active" && (
<>
<div {...stylex.props(styles.sectionHeading, styles.sectionHeadingRow)}>
<h2>Resolved</h2>
{historyRows.length > 0 && (
<button
type="button"
onClick={() => setPendingPurge("all")}
disabled={purging}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Purge all resolved
</button>
)}
</div>
{history.status === "error" ? (
<InlineError error={history.error} onRetry={() => void history.refetch()} />
) : history.data === undefined ? (
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
Loading history
</p>
) : historyRows.length === 0 ? (
<p {...stylex.props(styles.empty)}>Nothing has failed and recovered in the retained window.</p>
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead {...stylex.props(styles.head)}>
<tr>
<th {...stylex.props(styles.th)}>Severity</th>
<th {...stylex.props(styles.th)}>Event</th>
<th {...stylex.props(styles.th)}>Subject</th>
<th {...stylex.props(styles.th)}>Started</th>
<th {...stylex.props(styles.th)}>Resolved</th>
<th {...stylex.props(styles.th)}>Occurrences</th>
<th {...stylex.props(styles.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{historyRows.map((event) => (
<HistoryRow
key={event.id}
event={event}
busy={purging}
onPurge={() => setPendingPurge(event.id)}
/>
))}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footer, styles.note)}>
Showing {historyRows.length} resolved {historyRows.length === 1 ? "entry" : "entries"}
{hasMore(history) ? "" : " — end of history"}
</p>
<MoreButton section={history} />
</>
)}
<InlineError error={purgeOne.error ?? purgeAll.error} />
</>
)}
<ConfirmDialog
isOpen={pendingPurge !== null}
title={pendingPurge === "all" ? "Purge resolved history" : "Purge event"}
message={
pendingPurge === "all"
? "Purge all resolved events? Active events are kept."
: "Purge this resolved event? Its history is gone for good."
}
confirmLabel={pendingPurge === "all" ? "Purge all" : "Purge"}
onConfirm={confirmPurge}
onCancel={() => setPendingPurge(null)}
/>
</section>
);
}
@@ -0,0 +1,42 @@
/**
* The severity chip both diagnostics views carry. The word is the affordance
* colour alone would leave the severity unreadable to a screen reader and to
* anyone who does not separate the amber from the red.
*/
import * as stylex from "@stylexjs/stylex";
import type { DiagnosticSeverity } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
badge: {
display: "inline-block",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
whiteSpace: "nowrap",
},
warning: {
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
},
error: {
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
color: colors.dangerText,
},
});
export default function SeverityBadge({ severity }: { severity: DiagnosticSeverity }) {
return (
<span {...stylex.props(styles.badge, severity === "error" ? styles.error : styles.warning)}>
{severity === "error" ? "Error" : "Warning"}
</span>
);
}
@@ -0,0 +1,50 @@
import { DIAGNOSTIC_CODES } from "@/lib/types";
import { DIAGNOSTIC_COMPONENTS, EVENT_COPY, componentLabel, copyFor } from "./eventCopy";
test("the enum holds the fifteen codes the store defines", () => {
expect(DIAGNOSTIC_CODES).toHaveLength(15);
expect(new Set(DIAGNOSTIC_CODES).size).toBe(15);
});
test("every code has copy, and no copy belongs to a code that does not exist", () => {
for (const code of DIAGNOSTIC_CODES) {
const copy = EVENT_COPY[code];
expect(copy, code).toBeDefined();
expect(copy.title.length, code).toBeGreaterThan(0);
expect(copy.impact.length, code).toBeGreaterThan(0);
expect(copy.remediation.length, code).toBeGreaterThan(0);
}
expect(Object.keys(EVENT_COPY).sort()).toEqual([...DIAGNOSTIC_CODES].sort());
});
test("titles are distinct, so two open episodes never read as the same event", () => {
const titles = DIAGNOSTIC_CODES.map((code) => EVENT_COPY[code].title);
expect(new Set(titles).size).toBe(titles.length);
});
test("a code this build has never heard of falls back to the code itself", () => {
// The server is the authority on the enum; a newer one can send a sixteenth.
const copy = copyFor("nonsense.code" as (typeof DIAGNOSTIC_CODES)[number]);
expect(copy.title).toBe("nonsense.code");
expect(copy.remediation.length).toBeGreaterThan(0);
});
test("the component options are the code prefixes, deduplicated and in enum order", () => {
expect(DIAGNOSTIC_COMPONENTS).toEqual([
"disk",
"blocklist",
"certificate",
"query_log",
"upstream_history",
"upstream",
"client_names",
"clients",
"listener",
"configuration",
]);
});
test("component labels read as prose without inventing a name", () => {
expect(componentLabel("query_log")).toBe("Query log");
expect(componentLabel("disk")).toBe("Disk");
});
+155
View File
@@ -0,0 +1,155 @@
/**
* What each event code means to the operator, in three fixed fields: what the
* episode is (`title`), what it costs while it stays open (`impact`), and what
* to do about it (`remediation`). The server sends a code and an error string;
* every word of explanation the page shows comes from here.
*
* The record is exhaustive over `DiagnosticCode` by type, and a test walks
* `DIAGNOSTIC_CODES` to prove it at runtime too. A sixteenth code added to the
* enum fails `tsc` here before it can reach the page as a bare dotted string.
*
* `link` points at the configuration surface that governs the failure. Those
* are today's routes; the navigation restructure re-points them.
*/
import { DIAGNOSTIC_CODES, type DiagnosticCode } from "@/lib/types";
/** The literal paths keep `link.to` assignable to a typed router `Link`. */
export type CopyLinkPath = "/settings" | "/blocklists" | "/upstreams" | "/clients";
export interface EventCopy {
title: string;
impact: string;
remediation: string;
link?: { to: CopyLinkPath; label: string };
}
/**
* The copy for a code, with a floor under it. `tsc` proves the record covers
* the union, but a server one release ahead can send a code this build has
* never heard of; showing the raw code beats rendering "undefined".
*/
export function copyFor(code: DiagnosticCode): EventCopy {
return (
EVENT_COPY[code] ?? {
title: code,
impact: "This build has no description for this event code.",
remediation: "The error detail below is the whole of what the server reported.",
}
);
}
const SETTINGS = { to: "/settings", label: "Settings" } as const;
const BLOCKLISTS = { to: "/blocklists", label: "Blocklists" } as const;
const UPSTREAMS = { to: "/upstreams", label: "Upstreams" } as const;
const CLIENTS = { to: "/clients", label: "Clients" } as const;
/**
* The component filter's options, derived from the codes rather than listed
* again: the server matches `component` against the part of `code` before the
* dot, so any list written by hand here could drift from the enum.
*/
export const DIAGNOSTIC_COMPONENTS: readonly string[] = [
...new Set(DIAGNOSTIC_CODES.map((code) => code.slice(0, code.indexOf(".")))),
];
/** `query_log` → "Query log". Display only; the filter sends the raw component. */
export function componentLabel(component: string): string {
const spaced = component.replaceAll("_", " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
export const EVENT_COPY: Record<DiagnosticCode, EventCopy> = {
"disk.space": {
title: "Disk space low",
impact: "Below the critical threshold nxdns stops blocklist updates and query log flushes to protect the disk.",
remediation: "Free space on the data volume, or lower the retention window so the query log holds fewer days.",
link: SETTINGS,
},
"disk.probe": {
title: "Disk usage probe failed",
impact: "Free space is unknown, so the low-disk guard cannot act until a probe succeeds.",
remediation: "Check that the data and log directories exist and that the service user can read them.",
link: SETTINGS,
},
"blocklist.refresh": {
title: "Blocklist source failed to update",
impact: "The source keeps serving its last good snapshot, so blocking continues but the list ages.",
remediation: "Check the source url and the machine's internet access, then update the lists again.",
link: BLOCKLISTS,
},
"blocklist.snapshot": {
title: "Filter snapshot failed to publish",
impact: "The resolver keeps the snapshot it already holds; blocklist edits do not take effect until one publishes.",
remediation: "Check free disk space and the data directory's permissions, then update the lists again.",
link: BLOCKLISTS,
},
"blocklist.storage": {
title: "Blocklist storage operation failed",
impact: "Cached list files or their database rows are out of step; a later pass can redownload what is missing.",
remediation: "Check free disk space and the data directory's permissions.",
link: BLOCKLISTS,
},
"certificate.reload": {
title: "TLS certificate reload failed",
impact: "The endpoint keeps serving the certificate it already loaded, which expires on its own schedule.",
remediation:
"Check the certificate and key paths, and that renewal writes both files the service user can read.",
link: SETTINGS,
},
"query_log.write": {
title: "Query log write failed",
impact: "Queries are resolved and answered as usual, but they are not being recorded.",
remediation: "Check free disk space and the log database's permissions, then restart nxdns.",
link: SETTINGS,
},
"query_log.maintenance": {
title: "Query log maintenance failed",
impact: "Old rows are not being trimmed, so the log database grows past its retention window.",
remediation: "Check free disk space; the next maintenance pass retries on its own.",
link: SETTINGS,
},
"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.",
remediation: "Keep or delete the aside file named below. Nothing else is required — logging is running.",
link: SETTINGS,
},
"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.",
link: UPSTREAMS,
},
"upstream.exchange": {
title: "Upstream failing",
impact: "Queries fall through to the remaining upstreams; answers are slower while this one backs off.",
remediation: "Check the upstream's reachability and its TLS name. Remove it if it stays down.",
link: UPSTREAMS,
},
"client_names.storage": {
title: "Client name storage failed",
impact: "Learned reverse-DNS names are not persisted, so clients can show as bare addresses after a restart.",
remediation: "Check free disk space and the configuration database's permissions.",
link: CLIENTS,
},
"clients.storage": {
title: "Client record storage failed",
impact: "New clients may not appear in the list and stale ones may not be pruned.",
remediation: "Check free disk space and the configuration database's permissions.",
link: CLIENTS,
},
"listener.start": {
title: "Encrypted DNS listener failed to start",
impact: "That endpoint is not accepting queries. Plain DNS on port 53 is unaffected.",
remediation:
"Check the bind address, the port, and the certificate paths, then restart nxdns. The episode closes on a clean start.",
link: SETTINGS,
},
"configuration.load": {
title: "Configuration problem at startup",
impact: "The setting named below was rejected or replaced by its default for this run.",
remediation: "Correct the setting and restart nxdns. The episode closes on a clean start.",
link: SETTINGS,
},
};
@@ -0,0 +1,194 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import type { Client, LiveQueryEvent } from "@/lib/types";
import { FakeEventSource } from "./fakeEventSource";
import LiveLogPage from "./LiveLogPage";
function client(ip: string, name: string, learnedName: string): Client {
return {
id: Number(ip.split(".").pop()),
ip,
name,
learned_name: learnedName,
group_id: 1,
group: "default",
hand_edited: name !== "",
first_seen: 1_700_000_000,
last_seen: 1_700_000_100,
};
}
const CLIENTS: Client[] = [
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
client("192.0.2.11", "", "laptop.lan"),
client("192.0.2.12", "", ""),
];
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) !== "/api/clients") {
return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
}
return new Response(JSON.stringify({ clients: CLIENTS }), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
const payload: LiveQueryEvent = {
ts,
domain,
client_ip: "192.0.2.10",
qtype: 1,
blocked: false,
block_reason: "",
response_time_us: 500,
cache_hit: true,
upstream: "",
...overrides,
};
return { data: JSON.stringify(payload) };
}
function renderPage() {
const sources: FakeEventSource[] = [];
const createEventSource = (url: string) => {
const es = new FakeEventSource(url);
sources.push(es);
return es;
};
render(
<QueryClientProvider client={createQueryClient()}>
<LiveLogPage createEventSource={createEventSource} />
</QueryClientProvider>,
);
return sources;
}
test("streams rows, flags blocked ones, and freezes the display", () => {
const sources = renderPage();
expect(screen.getByText("Connecting…")).toBeTruthy();
act(() => sources[0]!.emit("open"));
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
act(() => {
sources[0]!.emit("query", frame(1000, "ok.example"));
sources[0]!.emit(
"query",
frame(1001, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack", qtype: 28 }),
);
});
expect(screen.getByText("ok.example")).toBeTruthy();
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
expect(screen.getByText("AAAA")).toBeTruthy();
// StyleX compiles to opaque class names, so the check is structural: a blocked
// row carries every class a plain row does, plus the ones the flag adds.
const blockedRow = screen.getByText("ads.example").closest("tr");
const plainRow = screen.getByText("ok.example").closest("tr");
const blockedClasses = new Set(blockedRow?.className.split(" "));
const plainClasses = plainRow?.className.split(" ") ?? [];
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
const freeze = screen.getByRole("button", { name: "Freeze" });
fireEvent.click(freeze);
expect(freeze.getAttribute("aria-pressed")).toBe("true");
act(() => sources[0]!.emit("query", frame(1002, "later.example")));
expect(screen.queryByText("later.example")).toBeNull();
expect(screen.getByText(/3 in buffer/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(screen.getByText("later.example")).toBeTruthy();
});
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
const sources = renderPage();
act(() => sources[0]!.emit("open"));
act(() => {
sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" }));
sources[0]!.emit("query", frame(1001, "learned.example", { client_ip: "192.0.2.11" }));
sources[0]!.emit("query", frame(1002, "nameless.example", { client_ip: "192.0.2.12" }));
sources[0]!.emit("query", frame(1003, "stranger.example", { client_ip: "192.0.2.99" }));
});
// A hand-typed name wins outright; the learned name never surfaces for it.
const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10");
expect(screen.queryByText("pi.lan")).toBeNull();
// A learned name reads muted and nothing more here: the "learned" tag would
// repeat on every row of the table, so the Clients page carries it instead.
const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
// A known client with neither name, and a client the loaded list has never
// seen, both fall back to the bare address with no tooltip standing in.
const nameless = screen.getByText("192.0.2.12");
expect(nameless.getAttribute("title")).toBeNull();
const stranger = screen.getByText("192.0.2.99");
expect(stranger.getAttribute("title")).toBeNull();
expect(screen.getByText("stranger.example").closest("tr")?.textContent).toContain("192.0.2.99");
});
test("rows stream in as bare IPs while the client list is still loading", async () => {
let releaseClients: () => void = () => {};
vi.stubGlobal(
"fetch",
vi.fn(
(input: RequestInfo | URL) =>
new Promise<Response>((resolve) => {
if (String(input) !== "/api/clients") {
resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
return;
}
releaseClients = () =>
resolve(
new Response(JSON.stringify({ clients: CLIENTS }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}),
),
);
const sources = renderPage();
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" })));
expect(screen.getByText("192.0.2.10")).toBeTruthy();
expect(screen.queryByText("Kitchen Pi")).toBeNull();
releaseClients();
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
});
test("repeated connection failures show the viewer-cap state with a retry button", () => {
const sources = renderPage();
act(() => {
sources[0]!.emit("error");
sources[0]!.emit("error");
sources[0]!.emit("error");
});
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(sources).toHaveLength(2);
expect(screen.getByText("Connecting…")).toBeTruthy();
});
@@ -1,4 +1,5 @@
import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames";
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
import { RING_CAPACITY } from "./ringBuffer";
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
@@ -168,6 +169,7 @@ function StatusPill({ status }: { status: StreamStatus }) {
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
const live = useLiveQueries({ createEventSource });
const clientNames = useClientNames();
return (
<section>
@@ -240,7 +242,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
<tbody>
{live.rows.map((row) => (
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
<QueryCells row={row} />
<QueryCells row={row} clientNames={clientNames} />
</tr>
))}
</tbody>
@@ -1,9 +1,29 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import type { QueriesPage, QueryRow } from "@/lib/types";
import type { Client, QueriesPage, QueryRow } from "@/lib/types";
import QueryLogPage from "./QueryLogPage";
function client(id: number, ip: string, name: string, learnedName: string): Client {
return {
id,
ip,
name,
learned_name: learnedName,
group_id: 1,
group: "default",
hand_edited: name !== "",
first_seen: 1_700_000_000,
last_seen: 1_700_000_100,
};
}
const CLIENTS: Client[] = [
client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
client(2, "192.0.2.11", "", "laptop.lan"),
client(3, "192.0.2.12", "", ""),
];
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
return {
id,
@@ -48,6 +68,7 @@ beforeEach(() => {
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/clients") return json({ clients: CLIENTS });
const payload = PAGES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
@@ -90,6 +111,44 @@ test("renders the first page with type names, blocked badge, and formatted cells
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
});
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [
row(20, "named.example", { client_ip: "192.0.2.10" }),
row(19, "learned.example", { client_ip: "192.0.2.11" }),
row(18, "nameless.example", { client_ip: "192.0.2.12" }),
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
],
next_before: null,
} satisfies QueriesPage);
}),
);
renderPage();
// A hand-typed name wins outright; the learned name never surfaces for it.
const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10");
expect(screen.queryByText("pi.lan")).toBeNull();
// A learned name reads muted and nothing more here: the "learned" tag would
// repeat on every row of the table, so the Clients page carries it instead.
const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
// A known client with neither name, and a client the loaded list has never
// seen, both fall back to the bare address with no tooltip standing in.
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
});
test("load more appends the next page and stops at the end of the log", async () => {
renderPage();
await screen.findByText("first.example");
@@ -5,6 +5,7 @@ import * as api from "@/lib/api";
import { formatMicros, formatTime } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueriesFilter, QueryRow } from "@/lib/types";
import { ClientName, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import { qtypeName } from "./qtype";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
@@ -162,12 +163,14 @@ export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_re
);
}
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
export function QueryCells({ row, clientNames }: { row: Omit<QueryRow, "id">; clientNames: ClientNames }) {
return (
<>
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
<ClientName ip={row.client_ip} names={clientNames} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell)}>
<BlockedCell row={row} />
@@ -212,6 +215,7 @@ export default function QueryLogPage() {
const [applied, setApplied] = useState<QueriesFilter>({});
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
const clientNames = useClientNames();
const pages = base.data?.pages ?? [];
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
@@ -336,7 +340,7 @@ export default function QueryLogPage() {
<tbody>
{rows.map((row) => (
<tr key={row.id} {...stylex.props(styles.row)}>
<QueryCells row={row} />
<QueryCells row={row} clientNames={clientNames} />
</tr>
))}
</tbody>
@@ -33,6 +33,7 @@ function baseSettings(): Settings {
level: "info",
retention_days: 30,
query_log_buffer_max: 10000,
query_log_flush_interval_s: 60,
hide_domains: false,
hide_client_ips: false,
output: "stderr",
@@ -116,6 +116,7 @@ const SECTIONS: readonly AnySectionDef[] = [
{ key: "level", kind: ["error", "warn", "info", "debug"] },
{ key: "retention_days", kind: "number" },
{ key: "query_log_buffer_max", kind: "number" },
{ key: "query_log_flush_interval_s", kind: "number" },
{ key: "hide_domains", kind: "boolean" },
{ key: "hide_client_ips", kind: "boolean" },
{ key: "output", kind: ["stderr", "syslog", "file"] },
@@ -38,6 +38,7 @@ function baseSettings(): Settings {
level: "info",
retention_days: 30,
query_log_buffer_max: 10000,
query_log_flush_interval_s: 60,
hide_domains: false,
hide_client_ips: false,
output: "stderr",
+20 -1
View File
@@ -6,6 +6,10 @@ import type {
ClientEdit,
ClientPrefix,
ClientPrefixInput,
DiagnosticEvent,
DiagnosticsFilter,
DiagnosticsPage,
DiagnosticsPurge,
ForwardZone,
ForwardZoneInput,
Group,
@@ -118,7 +122,22 @@ export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
export const getUpstreamHealth = (): Promise<UpstreamHealth> => request("/api/upstream/health");
export const getUpstreamHealth = (period?: Period): Promise<UpstreamHealth> =>
request(`/api/upstream/health${qs({ period })}`);
// Diagnostics
export const getDiagnostics = (filter: DiagnosticsFilter = {}): Promise<DiagnosticsPage> =>
request(`/api/diagnostics${qs({ ...filter })}`);
export const getDiagnostic = (id: number): Promise<DiagnosticEvent> => request(`/api/diagnostics/${id}`);
/** Purges one resolved event. An event still active answers 409, an unknown id 404. */
export const purgeDiagnostic = (id: number): Promise<void> => request(`/api/diagnostics/${id}`, { method: "DELETE" });
/** Purges the whole resolved history; active events are never touched. */
export const purgeResolvedDiagnostics = (): Promise<DiagnosticsPurge> =>
request("/api/diagnostics", { method: "DELETE" });
// Groups
@@ -9,13 +9,16 @@
// declare, and a string outside a literal union.
//
// Regenerate with:
// zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts"
// zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
import type {
Blocklist,
BlocklistEcho,
Client,
ClientPrefix,
DiagnosticEvent,
DiagnosticsPage,
DiagnosticsPurge,
ErrorEnvelope,
ForwardZone,
Group,
@@ -39,6 +42,11 @@ import type {
} from "@/lib/types";
export const sample_get_health: Health = {
diagnostics: {
active_errors: 0,
active_warnings: 0,
state: "recording",
},
disk: {
db_bytes: 0,
free_bytes: 0,
@@ -73,6 +81,57 @@ export const sample_logout: LogoutResponse = {
authenticated: false,
};
export const sample_get_diagnostics: DiagnosticsPage = {
active: {
errors: 0,
warnings: 0,
},
events: [
{
code: "upstream_history.write",
component: "upstream_history",
detail: "Busy",
first_seen: 0,
id: 0,
last_seen: 0,
occurrences: 0,
resolved_at: 0,
severity: "warning",
subject: "history",
},
{
code: "blocklist.refresh",
component: "blocklist",
detail: "download failed: ConnectionTimedOut",
first_seen: 0,
id: 0,
last_seen: 0,
occurrences: 0,
resolved_at: null,
severity: "warning",
subject: "StevenBlack",
},
],
next_before: null,
};
export const sample_get_diagnostic: DiagnosticEvent = {
code: "blocklist.refresh",
component: "blocklist",
detail: "download failed: ConnectionTimedOut",
first_seen: 0,
id: 0,
last_seen: 0,
occurrences: 0,
resolved_at: null,
severity: "warning",
subject: "StevenBlack",
};
export const sample_purge_diagnostics: DiagnosticsPurge = {
purged: 0,
};
export const sample_create_blocklist: BlocklistEcho = {
enabled: false,
id: 0,
@@ -333,16 +392,23 @@ export const sample_update_upstream: UpstreamEcho = {
export const sample_get_upstream_health: UpstreamHealth = {
available: 0,
complete: true,
period: "24h",
since: 0,
total: 0,
until: 0,
upstreams: [
{
available: true,
consecutive_failures: 0,
enabled: true,
last_error: "",
success_rate: 0,
total_failures: 0,
total_successes: 0,
period: {
attempts: 0,
failures: 0,
last_failure_at: null,
last_failure_error: null,
success_rate: null,
successes: 0,
},
url: "https://dns.example/dns-query",
},
],
@@ -491,6 +557,7 @@ export const sample_get_settings: SettingsEnvelope = {
"logging.level",
"logging.retention_days",
"logging.query_log_buffer_max",
"logging.query_log_flush_interval_s",
"logging.hide_domains",
"logging.hide_client_ips",
"logging.output",
@@ -552,6 +619,7 @@ export const sample_get_settings: SettingsEnvelope = {
max_size_mb: 0,
output: "stderr",
query_log_buffer_max: 0,
query_log_flush_interval_s: 0,
retention_days: 0,
},
upstream: {
@@ -614,6 +682,7 @@ export const sample_put_settings: SettingsEnvelope = {
"logging.level",
"logging.retention_days",
"logging.query_log_buffer_max",
"logging.query_log_flush_interval_s",
"logging.hide_domains",
"logging.hide_client_ips",
"logging.output",
@@ -675,6 +744,7 @@ export const sample_put_settings: SettingsEnvelope = {
max_size_mb: 0,
output: "stderr",
query_log_buffer_max: 0,
query_log_flush_interval_s: 0,
retention_days: 0,
},
upstream: {
@@ -6,4 +6,4 @@
pub const bytes = @embedFile("contractSamples.gen.ts");
/// Repo-relative path, so a failing assertion names the file to regenerate.
pub const path = "web/src/lib/contractSamples.gen.ts";
pub const path = "admin/src/lib/contractSamples.gen.ts";
+44
View File
@@ -0,0 +1,44 @@
import { formatAge, formatBytes, formatDuration, formatMicros, formatTime } from "@/lib/format";
test("formatTime renders unix seconds in the given locale and zone", () => {
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
expect(formatTime(1704067200, "en-US", "UTC").replace(//g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
});
test("formatBytes humanizes with binary units", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(1023)).toBe("1023 B");
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1536)).toBe("1.5 KiB");
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MiB");
expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe("3.0 GiB");
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
});
test("formatAge steps up a unit at each boundary and truncates", () => {
expect(formatAge(0)).toBe("0s ago");
expect(formatAge(59)).toBe("59s ago");
expect(formatAge(60)).toBe("1m ago");
expect(formatAge(3599)).toBe("59m ago");
expect(formatAge(3600)).toBe("1h ago");
expect(formatAge(10800)).toBe("3h ago");
expect(formatAge(86399)).toBe("23h ago");
expect(formatAge(86400)).toBe("1d ago");
expect(formatAge(400000)).toBe("4d ago");
});
test("formatDuration is the same span without the 'ago', and never negative", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(59)).toBe("59s");
expect(formatDuration(3600)).toBe("1h");
expect(formatDuration(86400)).toBe("1d");
// Clock skew between the server's timestamps and the browser's clock.
expect(formatDuration(-5)).toBe("0s");
});
test("formatMicros renders milliseconds with one decimal", () => {
expect(formatMicros(0)).toBe("0.0 ms");
expect(formatMicros(1234)).toBe("1.2 ms");
expect(formatMicros(999)).toBe("1.0 ms");
expect(formatMicros(2_500_000)).toBe("2500.0 ms");
});
+57
View File
@@ -0,0 +1,57 @@
/** Unix seconds → localized date-time. `locale`/`timeZone` exist for deterministic tests. */
export function formatTime(unixSeconds: number, locale?: string, timeZone?: string): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "medium",
timeZone,
}).format(new Date(unixSeconds * 1000));
}
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
let value = bytes;
let unit: string = BYTE_UNITS[0];
for (const next of BYTE_UNITS) {
unit = next;
value /= 1024;
if (value < 1024) break;
}
return `${value.toFixed(1)} ${unit}`;
}
const AGE_UNITS = [
{ seconds: 86400, suffix: "d" },
{ seconds: 3600, suffix: "h" },
{ seconds: 60, suffix: "m" },
] as const;
/**
* Seconds of elapsed time a coarse "3h ago". Truncating and single-unit on
* purpose: this labels a snapshot the caller renders once, so a reader must not
* take it for a live count. Nothing re-renders it as it ages.
*/
export function formatAge(seconds: number): string {
for (const unit of AGE_UNITS) {
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix} ago`;
}
return `${Math.floor(seconds)}s ago`;
}
/**
* Seconds of elapsed time a coarse "3h", the same single truncated unit as
* `formatAge` without the "ago". For a span the caller labels itself, as in
* "active for 3h". A negative span reads "0s": clock skew is not a duration.
*/
export function formatDuration(seconds: number): string {
for (const unit of AGE_UNITS) {
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix}`;
}
return `${Math.max(0, Math.floor(seconds))}s`;
}
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
export function formatMicros(micros: number): string {
return `${(micros / 1000).toFixed(1)} ms`;
}
@@ -5,6 +5,8 @@ import type {
BlocklistInput,
ClientEdit,
ClientPrefixInput,
DiagnosticsFilter,
DiagnosticsPage,
ForwardZoneInput,
GroupInput,
LocalRecordInput,
@@ -23,7 +25,11 @@ export const queryKeys = {
stats: (period: Period) => ["stats", period] as const,
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
upstreamHealth: ["upstream-health"] 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,
@@ -69,8 +75,37 @@ export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
placeholderData: keepPreviousData,
});
export const upstreamHealthQuery = () =>
queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 });
// Keyset pagination on `next_before`, exactly as the query log pages
// (handlers/diagnostics.zig copies the /api/queries contract). The active view
// polls on healthQuery's cadence because an episode opening is the same news a
// health banner carries; a resolved-history page is settled and does not poll.
// `enabled` belongs to the factory rather than to a spread at the call site:
// spreading the options object loses the page-param type, and the Diagnostics
// page turns one of its two sections off whenever a filter excludes it.
export const diagnosticsInfiniteQuery = (filter: DiagnosticsFilter = {}, enabled = true) =>
infiniteQueryOptions({
enabled,
queryKey: queryKeys.diagnosticsInfinite(filter),
queryFn: ({ pageParam }: { pageParam: number | undefined }) =>
api.getDiagnostics(pageParam === undefined ? filter : { ...filter, before: pageParam }),
initialPageParam: undefined as number | undefined,
getNextPageParam: (last: DiagnosticsPage) => last.next_before ?? undefined,
placeholderData: keepPreviousData,
refetchInterval: filter.state === "active" ? 10_000 : undefined,
});
export const diagnosticQuery = (id: number) =>
queryOptions({ queryKey: queryKeys.diagnostic(id), queryFn: () => api.getDiagnostic(id) });
// The period is part of the key: the upstream aggregates are ranged like the
// stats ones, so the picker has to refetch them rather than reuse a cached
// window under a new label.
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) });
@@ -105,6 +140,19 @@ export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings,
// Group membership and names feed lookup verdicts and the group columns on
// clients, prefixes and rules, hence the wide invalidation on group mutations.
// Both purges invalidate the whole `diagnostics` prefix rather than one page
// key: the resolved list, the active list (whose `active` counts ride along) and
// the detail query of the row just deleted all describe the table that changed.
export const diagnosticPurgeMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.purgeDiagnostic(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.diagnosticsAll }),
});
export const diagnosticsPurgeResolvedMutation = (qc: QueryClient) => ({
mutationFn: () => api.purgeResolvedDiagnostics(),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.diagnosticsAll }),
});
function invalidateGroupWorld(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.groups }),

Some files were not shown because too many files have changed in this diff Show More