75 Commits
Author SHA1 Message Date
mokhtar fa323c7ed4 milestone 29: activity — history, live and policy simulation on one surface
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
query log, live and lookup merge into /activity. history filters live
in the url, so a pasted link or back/forward reproduces the exact
view; the result column separates servfail and nxdomain from success
in the list. live is follow-by-default with freeze, and a streamed
row opens its in-memory provenance detail — no correlation invented
for rows sqlite has not written. lookup survives as the current
policy simulation under /activity/test. investigation links carry
absolute bounds, and the diagnostics page now honors since/until
instead of ignoring them. the old routes are gone without aliases.
2026-08-22 10:52:56 +02:00
mokhtar 0fd6bbd312 milestone 28: query provenance — every logged query is exactly explainable
Gates / frontend (push) Successful in 1m36s
Gates / test (push) Successful in 1m56s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 19m4s
query rows gain qclass, rcode, group, policy action and reason, the
matched rule or list entry with its source, cname and safe-search
targets, route kind, forward zone, and the resolver that actually
answered — the pool and local markers die. servfails are logged and
name the resolver that lost; post-parse protocol refusals become rows.
a detail page at /queries/:id renders the ordered explanation, and
coverage watermarks distinguish an empty history from a missing one.

the schema fingerprint changes: existing query history is recreated
with the old file kept aside and the reset filed as a resolved
diagnostic. fixes an oversized udp reply being rebuilt as noerror,
which handed clients a truncated nxdomain as success.
2026-08-22 09:16:40 +02:00
mokhtar 7e6cb507d2 release cut: bump-kind justfile recipe and a compiled, tested cut tool
Gates / frontend (push) Successful in 1m11s
Gates / test (push) Successful in 1m38s
Gates / test-aarch64 (push) Successful in 6m31s
Gates / container (push) Successful in 9s
CI / gates (push) Successful in 26m52s
Gates / package (push) Successful in 5m27s
2026-08-21 23:34:13 +02:00
mokhtar 8a17e9ed21 admin: fix dashboard phantom scroll, drop last-failure column from upstream table
Gates / frontend (push) Successful in 1m11s
Gates / test (push) Successful in 1m40s
Gates / test-aarch64 (push) Successful in 6m38s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 27m54s
the chart's screen-reader table wore srOnly directly; overflow and
height do not apply to a table box, so it laid out 1200px tall below
the page while clip-path hid the paint. wrap it in a hidden div, which
clips properly and keeps the table role. failure detail is the
diagnostics page's job since milestone 27; the column and the now
dead formatAge go.
2026-08-21 23:33:32 +02:00
mokhtar 0107df5f99 spec: measurement window is the deployment side's call
Gates / frontend (push) Successful in 1m13s
Gates / test (push) Successful in 1m37s
Gates / test-aarch64 (push) Successful in 6m35s
Gates / package (push) Successful in 5m40s
Gates / container (push) Successful in 9s
CI / gates (push) Successful in 22m27s
2026-08-21 18:49:05 +02:00
mokhtar b6cea3f539 build: bump version to 0.0.8
Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 1m53s
Gates / test-aarch64 (push) Successful in 6m34s
Gates / package (push) Successful in 43s
Gates / test-aarch64 (push) Successful in 7m23s
Gates / package (push) Successful in 4m11s
Gates / container (push) Successful in 18s
CI / gates (push) Successful in 27m37s
Release / guard (push) Successful in 18s
Gates / frontend (push) Successful in 1m9s
Gates / test (push) Successful in 1m36s
Gates / container (push) Successful in 10s
Release / gates (push) Successful in 9m6s
Release / publish (push) Successful in 4m33s
2026-08-21 18:43:49 +02:00
mokhtar 9b0b7c19f4 querylog: checkpoint every 8192 wal pages instead of 1000 2026-08-21 18:43:45 +02:00
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
mokhtar 5b3d1cd65c docs: unwrap hand-wrapped prose repo-wide
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
2026-08-15 16:27:36 +02:00
mokhtar 50b8fd5c61 changelog: unwrap hand-wrapped lines, release bodies render newlines as breaks
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m31s
Gates / package (push) Successful in 3m36s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 11m21s
Gates / test-aarch64 (push) Successful in 5m54s
2026-08-15 13:06:44 +02:00
mokhtar efbe355070 changelog: 0.0.3 releases today
Gates / frontend (push) Successful in 1m3s
Gates / test (push) Successful in 1m39s
Gates / test-aarch64 (push) Successful in 6m28s
Gates / container (push) Successful in 18s
Release / guard (push) Successful in 1m30s
Gates / test-aarch64 (push) Successful in 5m45s
Gates / package (push) Successful in 24s
Release / gates (push) Successful in 7m54s
Gates / package (push) Successful in 5m34s
CI / gates (push) Successful in 14m5s
Gates / frontend (push) Successful in 58s
Gates / test (push) Successful in 1m29s
Gates / container (push) Successful in 10s
Release / publish (push) Successful in 9m12s
2026-08-15 12:24:05 +02:00
mokhtar fc60214b3e ci: the container gate and the version parse move into a compiled tool 2026-08-15 12:24:05 +02:00
mokhtar 3c794b645b milestone 25: client names learned over reverse dns
Gates / test (push) Successful in 2m58s
Gates / frontend (push) Successful in 3m57s
Gates / test-aarch64 (push) Successful in 8m20s
Gates / package (push) Successful in 7m27s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 19m9s
2026-08-15 11:18:44 +02:00
mokhtar c428bc2398 ci: allow dispatching the gate set, a replaced runner pod kills the job it holds
Gates / frontend (push) Successful in 1m18s
Gates / test (push) Successful in 2m27s
Gates / test-aarch64 (push) Successful in 5m39s
Gates / package (push) Successful in 3m34s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 11m57s
Release / guard (push) Successful in 1m30s
Gates / frontend (push) Successful in 1m6s
Gates / test (push) Successful in 1m28s
Gates / test-aarch64 (push) Successful in 5m41s
Gates / package (push) Successful in 28s
Gates / container (push) Successful in 16s
Release / gates (push) Successful in 7m55s
Release / publish (push) Successful in 10m37s
2026-08-14 02:34:06 +02:00
mokhtar c7c1e21267 changelog: 0.0.2 releases today
Gates / frontend (push) Successful in 1m9s
Gates / test (push) Failing after 12m32s
Gates / test-aarch64 (push) Successful in 9m14s
Gates / package (push) Successful in 7m27s
Gates / container (push) Successful in 18s
CI / gates (push) Failing after 22m41s
2026-08-14 02:10:45 +02:00
mokhtar 21c5ce1f36 filter: separate the compiled bodies in the checksum, a name moving between them was invisible 2026-08-14 02:09:25 +02:00
mokhtar 1bce81eea0 milestone 24: persist and surface the unsupported-line count 2026-08-13 20:44:27 +02:00
mokhtar 21571e448e schema: collapse config.db to a single baseline, ddl_v1 stays editable until v0.1 2026-08-13 19:14:54 +02:00
mokhtar 2ab7c1f1de milestone 21: abp list exceptions and a regex rule kind 2026-08-13 19:14:47 +02:00
mokhtar b340521716 web tests: raise the async query budget, ci contention timed out a suspended page
Gates / test (push) Successful in 1m35s
Gates / test-aarch64 (push) Successful in 6m0s
Gates / frontend (push) Successful in 1m12s
Gates / package (push) Successful in 5m17s
Gates / container (push) Successful in 16s
CI / gates (push) Successful in 13m10s
2026-08-13 00:20:08 +02:00
mokhtar 13458a980e ci: run the frontend gate on the light runner
Gates / test (push) Successful in 1m37s
Gates / frontend (push) Failing after 3m23s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test-aarch64 (push) Successful in 6m2s
CI / gates (push) Failing after 7m41s
2026-08-12 23:46:07 +02:00
mokhtar e576704fad spec milestone 23: record the readme and conversion-order findings as closed by decision 2026-08-12 23:45:34 +02:00
mokhtar 4aaf6d3815 milestone 23: close the remaining codex findings
the vitest "something prevents Vite server from exiting" warning was
@stylexjs/unplugin, not our code: configureServer starts a 150ms polling
interval for HMR and clears it from server.httpServer's close event, which
vitest resolves to null in middleware mode. drop that one hook under VITEST
and keep the transform.

textMuted was swept rather than judged. measured against their grounds, the
39 flat text-zinc-500 sites went 4.12:1 -> 7.56:1 in dark mode, repairing a
WCAG AA failure, so that stands; the 4 already-adaptive sites went 7.40:1 ->
4.62:1 in light mode for nothing, so a textSecondary token restores their
original zinc-600/zinc-400 pair.

append the symbol families to the reset's font stack: Select renders U+25BE
and Segoe UI does not carry it.

correct two acceptance criteria that were literally false: both greps match
only comments.
2026-08-12 23:43:22 +02:00
mokhtar 0ea2e2905a milestone 23: restore the focus ring on select options, check layer order, close preflight gaps 2026-08-12 23:27:28 +02:00
mokhtar 9d5120cbad milestone 23: put the reset in a cascade layer, guard that it stays there 2026-08-12 23:06:26 +02:00
mokhtar 65f76d4427 milestone 23 s3: plan names stylex and react aria 2026-08-12 22:50:34 +02:00
mokhtar be12587b87 milestone 23 s3: remove tailwind, replace its preflight with an explicit reset 2026-08-12 22:50:25 +02:00
mokhtar 122044e6af milestone 23 s3: delete ui/classes.ts, its last importer is gone 2026-08-12 22:46:29 +02:00
mokhtar 0e9433d53b milestone 23 s3: convert features/queries/QueryLogPage.tsx to stylex 2026-08-12 22:45:53 +02:00
mokhtar ae9f7fb9f3 milestone 23 s3: convert features/lookup/LookupPage.tsx to stylex 2026-08-12 22:44:23 +02:00
mokhtar 1cd8f71a9b milestone 23 s3: convert features/dashboard/TimeseriesChart.tsx to stylex 2026-08-12 22:42:11 +02:00
mokhtar 15c895e180 milestone 23 s3: convert features/settings/SettingsPage.tsx to stylex 2026-08-12 22:40:50 +02:00
mokhtar 3b33d7afee milestone 23 s3: convert features/dashboard/UpstreamHealthTable.tsx to stylex 2026-08-12 22:38:15 +02:00
mokhtar f2b4582c0a milestone 23 s3: convert shell/AppShell.tsx to stylex 2026-08-12 22:37:16 +02:00
mokhtar 02bf1ca310 milestone 23 s3: convert features/live/LiveLogPage.tsx to stylex 2026-08-12 22:36:09 +02:00
mokhtar 4379d7599b milestone 23 s3: convert features/groups/GroupsPage.tsx to stylex 2026-08-12 22:33:58 +02:00
mokhtar 1246791fe1 milestone 23 s3: convert features/dashboard/DiskCard.tsx to stylex 2026-08-12 22:32:56 +02:00
mokhtar 2f3115117f milestone 23 s3: convert features/blocklists/SourceStatusSection.tsx to stylex 2026-08-12 22:31:14 +02:00
mokhtar e98896fc0a milestone 23 s3: convert features/dashboard/DashboardPage.tsx to stylex 2026-08-12 22:30:25 +02:00
mokhtar 1de608f9bc milestone 23 s3: convert auth/LoginPage.tsx to stylex 2026-08-12 22:28:56 +02:00
mokhtar 38d34453bb milestone 23 s3: convert features/groups/GroupSourcesEditor.tsx to stylex 2026-08-12 22:28:01 +02:00
mokhtar 368149a358 milestone 23 s3: convert features/blocklists/BlocklistForm.tsx to stylex 2026-08-12 22:27:00 +02:00
mokhtar ac0699faa1 milestone 23: amend two rulings the s2 review found the code in breach of 2026-08-12 22:26:42 +02:00
mokhtar d5b351d7fe milestone 23 s3: convert routes.tsx to stylex 2026-08-12 22:25:56 +02:00
mokhtar cddefa87e4 milestone 23 s3: convert features/pause/PauseWidget.tsx to stylex 2026-08-12 22:25:08 +02:00
mokhtar 9e12683acf milestone 23 s3: convert features/dashboard/StatCards.tsx to stylex 2026-08-12 22:23:16 +02:00
mokhtar 0fdbe25f7a milestone 23 s3: convert features/settings/RestartBanner.tsx to stylex 2026-08-12 22:21:53 +02:00
mokhtar 7e7358b3c2 milestone 23 s3: convert features/settings/ReadOnlyConfigBanner.tsx to stylex 2026-08-12 22:21:13 +02:00
mokhtar d5c4a8d978 milestone 23 s3: convert lib/InlineError.tsx to stylex 2026-08-12 22:19:59 +02:00
mokhtar 73bc180c67 milestone 23 s3: convert features/dashboard/HealthBanners.tsx to stylex 2026-08-12 22:18:23 +02:00
mokhtar 0f58966b31 milestone 23 s3: move srOnly into the shared style vocabulary 2026-08-12 22:16:58 +02:00
mokhtar bb39539fdf milestone 23: correct three comments the s1 review found inaccurate 2026-08-12 22:13:49 +02:00
mokhtar 01e455c8af milestone 23 s2: react aria primitives and their cluster 2026-08-12 22:12:18 +02:00
mokhtar 994bbf922c milestone 23 s1: stylex build integration, tokens and shared styles 2026-08-12 21:41:32 +02:00
mokhtar 7b0527d271 milestone 22: typescript 7 2026-08-12 21:30:40 +02:00
mokhtar c5e6ab9180 spec milestones 22 and 23: typescript 7, stylex and react aria 2026-08-12 21:30:35 +02:00
mokhtar e3529e4e61 ci: gates build the web bundle and the dist payload once, pass them between jobs
Gates / test (push) Successful in 1m37s
Gates / test-aarch64 (push) Successful in 6m46s
Gates / frontend (push) Successful in 1m6s
Gates / package (push) Successful in 4m4s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m54s
2026-08-12 20:20:30 +02:00
mokhtar 91a0aa92c6 ci: smoke test runs the container in file mode, surface its logs on failure
Gates / test-aarch64 (push) Successful in 5m55s
Gates / test (push) Successful in 1m27s
Gates / frontend (push) Successful in 50s
Gates / package (push) Successful in 3m52s
Gates / container (push) Successful in 2m28s
CI / gates (push) Successful in 14m34s
2026-08-12 00:15:22 +02:00
mokhtar 79a161e73a spec milestone 21: re-verify anchors against m20, record authority-mode interaction
Gates / test (push) Successful in 1m27s
Gates / test-aarch64 (push) Successful in 5m56s
Gates / frontend (push) Successful in 49s
Gates / package (push) Successful in 3m54s
Gates / container (push) Failing after 2m20s
CI / gates (push) Failing after 14m27s
2026-08-11 23:37:20 +02:00
mokhtar d76afc147a milestone 20: declarative configuration for iac 2026-08-11 23:31:40 +02:00
mokhtar 2f29121e27 spec milestone 21: abp list exceptions and operator regex rules 2026-08-11 19:03:58 +02:00
mokhtar 9aa396a65a spec milestone 20: declarative configuration for iac 2026-08-11 18:49:03 +02:00
mokhtar 929626b1dc milestone 14: verify-a-release walkthrough run against v0.0.1, acceptance closed 2026-08-09 01:35:43 +02:00
340 changed files with 48655 additions and 15994 deletions
+6
View File
@@ -9,6 +9,12 @@ on:
branches: [master]
pull_request:
branches: [master]
# A runner pod replaced mid-job kills the job it is running: the step's log
# stops dead, no epilogue is written, and Gitea marks the orphan failed.
# Nothing in the tree is wrong when that happens and nothing in the tree can
# fix it, so the gate set must be re-runnable without an empty commit. This
# adds a trigger, not a check — the job list below stays the gate set alone.
workflow_dispatch:
jobs:
gates:
+175 -218
View File
@@ -14,9 +14,28 @@ name: Gates
# token. A moved tag upstream would execute there. ci.yml itself still uses no
# third-party action; its only `uses:` is this workflow.
#
# Resolved with `git ls-remote <repo> refs/tags/vN 'refs/tags/vN^{}'` on
# 2026-08-07. All three are lightweight tags — no `^{}` peel row — so the
# listed SHA is the commit.
# Resolved with `git ls-remote <repo> refs/tags/vN 'refs/tags/vN^{}'` — the
# three originals on 2026-08-07, the two artifact actions on 2026-08-12. Every
# tag pinned here is lightweight — no `^{}` peel row — so the listed SHA is the
# commit. download-artifact's `v3-node20` is the exception that proves the
# rule: it is annotated, and its peel row names a different commit than the tag
# object. This file pins `v3.1.0-node20`, which is lightweight.
#
# The artifact actions are pinned to v3, and to the node20 rebuilds of v3, for
# two separate reasons.
#
# v4 and v5 abort in the *client*, not the server. @actions/artifact asks
# isGhes(), Gitea answers the way GitHub Enterprise Server does, and the action
# refuses before it uploads anything. The server side is present: Gitea 1.27.1
# routes the whole v4 twirp service — CreateArtifact, FinalizeArtifact,
# ListArtifacts, GetSignedArtifactURL and DeleteArtifact all answer 401, where
# an unrouted path answers 404. The forks that patch isGhes() out are
# third-party code, which is what the pinning rule above exists to keep away
# from the job that holds the signing subkey.
#
# The stock v3 tags — upload ff15f030 (v3.2.1), download 9bc31d5c (v3.0.2) —
# declare `using: node16`, and a runner is not required to still ship a node16.
# The `-node20` rebuilds are that same action code on a runtime that exists.
on:
workflow_call:
@@ -29,8 +48,9 @@ env:
# version to exist in the tag and in build.zig.zon and nowhere else, and
# ruling 5 makes verify-dist fail when the version under build disagrees with
# build.zig.zon. An invented CI string such as "0.0.0-ci" therefore cannot
# pass this file's own packaging gate. The package and container jobs read
# the version out of build.zig.zon instead.
# pass this file's own packaging gate. The package job reads the version out
# of build.zig.zon instead, and the container job takes it from that job's
# output.
jobs:
test:
@@ -85,7 +105,11 @@ jobs:
run: zig build test-aarch64 -fqemu
frontend:
runs-on: ubuntu-24.04
# The light runner, a second act_runner at capacity 1 that advertises only
# this label. This job peaks around 355 MB (tsc), well inside that runner's
# 1536Mi dind limit, and it runs no docker command — so it overlaps the
# heavy runner's zig and image work instead of queueing behind it.
runs-on: ubuntu-24.04-light
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -95,44 +119,72 @@ 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
# their own. The upload is last on purpose: a bundle that failed the
# licence inventory or any check above must not reach the jobs that
# package it into a tarball and an image.
#
# Artifact names are scoped to the run, so the ci.yml and release.yml
# runs that share this runner cannot read each other's.
#
# A later move to v4 has to add `include-hidden-files: true` here.
# `npm run build` writes the freshness stamp to the hidden file
# 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 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: admin-dist
path: admin/dist
if-no-files-found: error
package:
needs: [frontend]
runs-on: ubuntu-24.04
# The container job needs the version and installs no Zig, so it cannot read
# build.zig.zon the way this job does.
outputs:
version: ${{ steps.zon-version.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -149,30 +201,37 @@ jobs:
- name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
# `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 `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 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:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
# `dist` refuses web/dist-placeholder (ruling 4), so the real bundle has
# to exist before the packaging gate runs.
- name: Build the web UI
working-directory: web
run: |
npm ci
npm run build
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
# gate builds the version the repository declares.
#
# This is the only job that reads it. The container job used to run its own
# `sed` over the same file; it now receives this step's output, so the two
# jobs cannot disagree about what the repository declares. The parse itself
# matches verify-dist's, through the zon grammar rather than a regex.
- name: Build the container gate tool
run: zig build container-check-tool
- name: Read the version from build.zig.zon
run: |
set -euo pipefail
version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1)
test -n "$version"
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
id: zon-version
run: ./zig-out/bin/container_check version
- name: Build the release artifacts
run: |
@@ -180,7 +239,7 @@ jobs:
zig build dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web/dist \
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
# verify-dist owns every assert the CI shell used to make: ELF static
@@ -196,204 +255,96 @@ jobs:
zig build verify-dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web/dist \
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
# deploy/docker/Dockerfile copies both of these trees and nothing else
# out of zig-out/dist: the binary comes from dist/bin/<triple>/, and
# /LICENSE and /THIRD-PARTY-NOTICES come from the matching dist/stage/
# directory, which the container job then hashes against the image. The
# tarballs and SHA256SUMS stay behind — no later job reads them, and
# verify-dist has already checked them here.
#
# The zip round-trip drops the executable bit. That is survivable only
# because the Dockerfile chmods the binary itself and the contents
# assertion compares sha256 of file contents, never modes. The archive
# modes are asserted by verify-dist, above, on the originals. The gate tool
# rides along in the same artifact and the container job chmods it back.
#
# Adding zig-out/bin/container_check moves the artifact's common root from
# zig-out/dist up to zig-out, which is why the container job restores into
# zig-out rather than zig-out/dist. The Dockerfile's COPY paths still
# resolve; they are relative to the repository root either way.
- name: Upload the staged payload for the container job
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with:
name: dist-payload
path: |
zig-out/dist/bin
zig-out/dist/stage
zig-out/bin/container_check
if-no-files-found: error
container:
needs: [package]
runs-on: ubuntu-24.04
env:
# Ruling 2 and ruling 5: one parse of build.zig.zon per run, done in the
# package job. This job installs no Zig and cannot repeat it.
CI_VERSION: ${{ needs.package.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
# Restored under zig-out because that is where the Dockerfile's COPY lines
# look for dist/, with the repository root as the build context, and
# because the payload also carries bin/container_check. This job compiles
# nothing and bundles nothing: the payload is the one the package job
# already built and verify-dist already checked, which is also the point —
# an image built from a second, independent `dist` run would prove nothing
# about the artifacts the release publishes.
- name: Download the staged payload built by the package job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
version: ${{ env.ZIG_VERSION }}
name: dist-payload
path: zig-out
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
# setup-zig's restored cache never contains tmp/. Without this, every
# dependency fetch dies with "failed to create temporary zip file:
# FileNotFound" before any network I/O.
- name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
# The artifact zip carries no modes.
- name: Restore the gate tool's executable bit
run: chmod +x zig-out/bin/container_check
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
# Everything this gate decides — this run's docker object names, the
# ownership label, the image build, the contents assertion against the
# staged payload, and the smoke test with its topology detection and its
# absolute probe deadline — lives in tools/container_check.zig, along with
# the rationale comments that used to sit in this file. That is the same
# move release.yml made (milestone-14 deviation 24): logic in a `run:`
# block cannot be type-checked, run on a laptop, or covered by a test.
- name: Run the container acceptance gate
run: ./zig-out/bin/container_check gate
- name: Build the web UI
working-directory: web
run: |
npm ci
npm run build
# Same single source of truth as the package job (rulings 2 and 5).
- name: Read the version from build.zig.zon
run: |
set -euo pipefail
version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1)
test -n "$version"
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
- name: Build the release artifacts
run: |
set -euo pipefail
zig build dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web/dist \
-Doptimize=ReleaseSafe
# Image tags, container names and published host ports are all
# daemon-global. This workflow is called by both ci.yml and release.yml
# and the self-hosted runners share one docker daemon, so the fixed
# `nxdns:ci` and `nxdns-smoke` made two concurrent runs collide: the
# second `docker create --name` fails outright, and worse, whichever run
# finishes first deletes the other's container mid-test. The names below
# carry the run identity plus four random bytes — the run id alone is not
# enough, because two *repositories* on the same daemon can issue the
# same one. The published port becomes ephemeral for the same reason.
- name: Name this run's docker objects
run: |
set -euo pipefail
rand=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
suffix="${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-$rand"
{
echo "CI_IMAGE=nxdns:ci-$suffix"
echo "SMOKE_NAME=nxdns-smoke-$suffix"
} >> "$GITHUB_ENV"
echo "image nxdns:ci-$suffix, container nxdns-smoke-$suffix"
# The build args carry the OCI label values (ruling 6); release.yml
# passes the same three and then asserts the resulting
# org.opencontainers.image.version label. BuildKit is not optional here:
# the builder stage is pinned to $BUILDPLATFORM, which the classic
# builder does not define, so DOCKER_BUILDKIT=0 fails at the first FROM.
- name: Build the image
env:
DOCKER_BUILDKIT: "1"
run: |
set -euo pipefail
docker build -t "$CI_IMAGE" -f deploy/docker/Dockerfile \
--build-arg VERSION="$CI_VERSION" \
--build-arg REVISION="$GITHUB_SHA" \
--build-arg CREATED="1970-01-01T00:00:00Z" \
.
# Ruling 6: the binary in the image must be the binary in the tarball.
# Ruling 3: distributing the image is distribution, so /LICENSE and
# /THIRD-PARTY-NOTICES must be in it and must be the same files the
# tarball carries — that is an acceptance criterion and nothing checked
# it. Comparing against the staged payload rather than merely asserting
# the paths exist costs nothing and catches a stale or empty copy.
# The gate removes its own container and image on every exit path it
# survives. This step is the exit path for a cancelled job and for a step
# that died before the gate's own defers ran — on a long-lived self-hosted
# daemon those accumulate one layer set per run. The name pair only exists
# if the gate got as far as writing $GITHUB_ENV, so the label sweep covers
# the rest of this run.
#
# Native triple only: this job builds a single-architecture image.
# release.yml covers both platforms against the pushed multi-arch index.
- name: Assert the image contents match the packaged artifacts
run: |
set -euo pipefail
stage="zig-out/dist/stage/nxdns-$CI_VERSION-x86_64-linux-musl"
test -d "$stage"
out=$(mktemp -d)
cid=$(docker create "$CI_IMAGE")
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true; rm -rf "$out"' EXIT
rc=0
for member in nxdns LICENSE THIRD-PARTY-NOTICES; do
docker cp "$cid:/$member" "$out/$member"
want=$(sha256sum "$stage/$member" | cut -d' ' -f1)
got=$(sha256sum "$out/$member" | cut -d' ' -f1)
if [ "$want" = "$got" ]; then
echo "/$member matches ($got)"
else
echo "/$member DIFFERS: image $got, packaged $want"
rc=1
fi
done
test "$rc" -eq 0
- name: Smoke test the container
run: |
set -euo pipefail
docker run --rm "$CI_IMAGE" version
mkdir -p etc-nxdns
cat > etc-nxdns/config.zon <<'EOF'
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
EOF
# No bind mount: the runner talks to the daemon over a mounted
# socket, so a -v path would resolve on the docker host (where the
# workspace does not exist) and mount an empty directory over
# /etc/nxdns. docker cp streams the file through the socket instead.
#
# Networking: this job itself runs in a container on the runner's
# per-job network. A published port binds on the daemon's host, not
# here, and docker does not route between the default bridge and
# that network — a bridge-IP curl hangs to its connect timeout. So
# the smoke container joins the job's own network, where its name
# resolves and its port is reachable. On a host runner the inspect
# finds no container and the published-port path covers it.
#
# `-p 127.0.0.1::8080` takes an ephemeral host port instead of a
# fixed 18080, which two concurrent runs on this daemon cannot both
# bind. The actual port is read back with `docker port`.
net=$(docker inspect "$(hostname)" \
-f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' \
2>/dev/null || true)
cid=$(docker create --name "$SMOKE_NAME" \
${net:+--network "$net"} \
-p 127.0.0.1::8080 \
"$CI_IMAGE")
trap 'docker rm -f "$SMOKE_NAME" >/dev/null 2>&1 || true' EXIT
docker cp etc-nxdns/config.zon "$SMOKE_NAME:/etc/nxdns/config.zon"
docker start "$SMOKE_NAME"
hostport=$(docker port "$SMOKE_NAME" 8080/tcp 2>/dev/null | head -1 | awk -F: '{ print $NF }')
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid")
echo "published host port: ${hostport:-none}, container ip: ${ip:-none}"
healthy=""
for _ in $(seq 1 30); do
if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then
echo "container exited during startup"
docker logs "$cid" || true
exit 1
fi
if curl -fsS --connect-timeout 2 "http://$SMOKE_NAME:8080/api/health" \
|| { [ -n "$hostport" ] && curl -fsS --connect-timeout 2 "http://127.0.0.1:$hostport/api/health"; } \
|| { [ -n "$ip" ] && curl -fsS --connect-timeout 2 "http://$ip:8080/api/health"; }; then
healthy=1
break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "no /api/health response within 30 seconds"
docker logs "$cid" || true
exit 1
fi
docker stop -t 30 "$SMOKE_NAME"
exit_code=$(docker inspect -f '{{.State.ExitCode}}' "$SMOKE_NAME")
echo "exit code after SIGTERM: $exit_code"
docker logs "$SMOKE_NAME" || true
test "$exit_code" -eq 0
# The per-step traps only cover the step that set them. This is the exit
# path for a cancelled job, a step that died before its trap was
# installed, and the image itself, which no trap ever removed — on a
# long-lived self-hosted daemon those accumulate one layer set per run.
# It covers no more than that. `always()` does not run when the runner or
# the pod itself dies, and the filter below names THIS attempt's label
# value exactly — deliberately, since a concurrent run of another
# repository must not be swept, but that also means a later attempt cannot
# collect an earlier one's leak. What the label buys for those cases is
# discovery, not recovery: `docker ps -a --filter
# label=net.mial.nxdns.ci` and the matching `docker images` list every
# object this workflow has ever left behind, with the repository, run and
# attempt that owns each one. Reclaiming them is a manual sweep today, and
# the hook a janitor job would use later.
- name: Remove this run's docker objects
if: always()
env:
CI_LABEL: net.mial.nxdns.ci=${{ github.repository }}/${{ github.run_id }}/${{ github.run_attempt }}
run: |
set -uo pipefail
if [ -n "${SMOKE_NAME:-}" ]; then
@@ -402,4 +353,10 @@ jobs:
if [ -n "${CI_IMAGE:-}" ]; then
docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true
fi
for cid in $(docker ps -aq --filter "label=$CI_LABEL"); do
docker rm -f "$cid" >/dev/null 2>&1 || true
done
for iid in $(docker images -q --filter "label=$CI_LABEL"); do
docker image rm -f "$iid" >/dev/null 2>&1 || true
done
exit 0
+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 -3
View File
@@ -1,6 +1,7 @@
.zig-cache/
zig-out/
zig-pkg/
web/node_modules/
web/dist/
web/dist-sourcemap/
admin/node_modules/
admin/dist/
admin/dist-sourcemap/
admin-dist-ci/
+19 -56
View File
@@ -2,79 +2,42 @@
## Aim
nxdns: a self-hosted DNS sinkhole for a household LAN, written in Zig 0.16.0.
Portfolio-grade public repo. PLAN.md is the source of truth for scope and design;
specs/ holds per-milestone contracts; specs/research/ holds verified stdlib facts.
nxdns: a self-hosted DNS sinkhole for a household LAN, written in Zig 0.16.0. Portfolio-grade public repo. PLAN.md is the source of truth for scope and design; specs/ holds per-milestone contracts; specs/research/ holds verified stdlib facts.
## Values
We intentionally architect this code to be robust, maintainable, pragmatic —
good craftsmanship and good engineering. We explicitly avoid tech debt, code
smells, bad architecture decisions, and brittle implementations.
We intentionally architect this code to be robust, maintainable, pragmatic — good craftsmanship and good engineering. We explicitly avoid tech debt, code smells, bad architecture decisions, and brittle implementations.
What that means in practice:
- This is a greenfield project. Breaking changes are allowed. Never keep a bad
interface for compatibility; fix it at the root.
- No versioning of scope. A feature is in scope (build it completely) or out of
scope (do not build it). No "v2 later", no stubs left behind.
- This is a greenfield project. Breaking changes are allowed. Never keep a bad interface for compatibility; fix it at the root.
- No versioning of scope. A feature is in scope (build it completely) or out of scope (do not build it). No "v2 later", no stubs left behind.
- Fix root causes, not symptoms. Do not iterate on workarounds.
- Scope is small on purpose: household scale, two targets, few dependencies.
Do not add generality nobody asked for.
- Dependencies are liabilities: stdlib first; vendored + pinned C deps
(sqlite3, mbedTLS) only where the stdlib has nothing.
- Verify stdlib claims against ../zig at tag 0.16.0 — pre-0.16 knowledge is
stale (std.Io migration). See specs/research/zig-0.16-api-notes.md.
- Pure core: dns/, filter/, local/, cache/ take bytes and return bytes — no Io,
no sockets, no clocks hidden inside.
- Every failure mode must be visible: no silent drops, no unbounded logs, no
swallowed errors. Counters + health surfaces over log spam.
- Tests are runnable acceptance criteria, not decoration. Required CI stays
deterministic — no network-dependent tests in blocking jobs.
- Comments state constraints the code cannot show. No narration, no
commented-out code.
- Git: GPG-signed commits (`git commit -S`), simple lowercase messages, no
generated-by footers.
- Scope is small on purpose: household scale, two targets, few dependencies. Do not add generality nobody asked for.
- Dependencies are liabilities: stdlib first; vendored + pinned C deps (sqlite3, mbedTLS) only where the stdlib has nothing.
- Verify stdlib claims against ../zig at tag 0.16.0 — pre-0.16 knowledge is stale (std.Io migration). See specs/research/zig-0.16-api-notes.md.
- Pure core: dns/, filter/, local/, cache/ take bytes and return bytes — no Io, no sockets, no clocks hidden inside.
- Every failure mode must be visible: no silent drops, no unbounded logs, no swallowed errors. Counters + health surfaces over log spam.
- Tests are runnable acceptance criteria, not decoration. Required CI stays deterministic — no network-dependent tests in blocking jobs.
- Comments state constraints the code cannot show. No narration, no commented-out code.
- Git: GPG-signed commits (`git commit -S`), simple lowercase messages, no generated-by footers.
## Reading `zig build test` output
A fully passing `zig build test` still prints a line like `failed command:
.../test --cache-dir=... --seed=... --listen=-`, and still exits 0. That line
is a known upstream zig 0.16.0 labelling defect. It does not mean a test
failed, and no test binary crashed.
A fully passing `zig build test` still prints a line like `failed command: .../test --cache-dir=... --seed=... --listen=-`, and still exits 0. That line is a known upstream zig 0.16.0 labelling defect. It does not mean a test failed, and no test binary crashed.
The build runner sets a step's `result_failed_command` on every spawn
(`std/Build/Step/Run.zig:1540`) and never clears it on success. It then prints
a step's diagnostics whenever the step wrote anything to stderr, explicitly "no
matter the result" (`compiler/build_runner.zig:1381`), and that printer emits
the `failed command: ` label unconditionally when the field is set
(`compiler/build_runner.zig:1515`). Our suite writes to stderr on every run,
because the tests that cover the warning paths log through the real sink. A
minimal reproducer with no mbedTLS and no C — one passing test whose body is a
`std.debug.print` — prints the same label and reports "3/3 steps succeeded;
1/1 tests passed"; deleting the print removes the label. No upstream issue
matched a search, so the reference is the 0.16.0 source lines above.
The build runner sets a step's `result_failed_command` on every spawn (`std/Build/Step/Run.zig:1540`) and never clears it on success. It then prints a step's diagnostics whenever the step wrote anything to stderr, explicitly "no matter the result" (`compiler/build_runner.zig:1381`), and that printer emits the `failed command: ` label unconditionally when the field is set (`compiler/build_runner.zig:1515`). Our suite writes to stderr on every run, because the tests that cover the warning paths log through the real sink. A minimal reproducer with no mbedTLS and no C — one passing test whose body is a `std.debug.print` — prints the same label and reports "3/3 steps succeeded; 1/1 tests passed"; deleting the print removes the label. No upstream issue matched a search, so the reference is the 0.16.0 source lines above.
Any *other* failure text is real. Trust the summary line: `zig build test`
exiting non-zero, a `N failed` count, or a panic backtrace all mean a genuine
failure. Do not filter, wrap, or suppress the runner's output to hide the
label — that would hide real failures with it.
Any *other* failure text is real. Trust the summary line: `zig build test` exiting non-zero, a `N failed` count, or a panic backtrace all mean a genuine failure. Do not filter, wrap, or suppress the runner's output to hide the label — that would hide real failures with it.
One trap: running a cached test binary by hand with `--listen=-` aborts with
`internal test runner failure: EndOfStream`. That is not a teardown bug; the
IPC runner is talking to a closed stdin because no build runner is on the other
end. Run the binary with no arguments to get the plain stdio report.
One trap: running a cached test binary by hand with `--listen=-` aborts with `internal test runner failure: EndOfStream`. That is not a teardown bug; the IPC runner is talking to a closed stdin because no build runner is on the other end. Run the binary with no arguments to get the plain stdio report.
## 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.
+141 -49
View File
@@ -1,62 +1,154 @@
# Changelog
All notable changes to nxdns are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
All notable changes to nxdns are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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.
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]
Query provenance: every logged query becomes exactly explainable — what the policy decided, what matched, where the answer came from and what the client saw. The handler records all of it as the reply goes out, `query_log` stores it, and a detail page reads one query back in the order the pipeline decided it. Read the upgrade note below first: it resets your query history.
### Added
- **Every logged query has a detail page.** A row in Activity now links to `/activity/queries/{id}`, which explains that one query in the order it was decided: the request, the group it was matched under, the policy verdict with the rule that produced it and the blocklist source that rule came from, any CNAME uncloaking or safe-search rewrite, the route the answer took — blocked, local, forward zone, upstream or cache — and what the client got back, RCODE and duration included. `GET /api/queries/{id}` serves the same object; an id that retention has already deleted is a 404. The live view carries the same provenance for the queries it streams, so a query is explainable as it happens as well as afterwards.
- **Query Log, Live and Lookup are one Activity page.** `/activity` is the single surface for what nxdns answered: History reads the stored log, Live reads the stream, and both show the same seven columns — Time, Domain, Client, Type, Result, Route, Duration. The mode and every filter live in the URL, so an investigation is one link that shows the recipient exactly what you were looking at, and an absolute time range stays that range instead of drifting as the day goes on. A new **Result** column says what the client actually got — `Blocked`, `NOERROR`, `SERVFAIL` and the rest — with the **Route** column beside it saying how the answer was produced, which is the pair the old Status column could not show: a blocked name is answered with NOERROR, and reading only the code made a block look like a success. Both unhappy cases are marked by weight and shape as well as colour. Switching between History and Live keeps your filters, and leaving Live closes the stream instead of holding a viewer slot open. A live row that the log has not written yet opens its own provenance in place — no invented row id — and the open detail stays put while the 500-row buffer scrolls past underneath it. Domain testing moves to `/activity/test` as **Current policy simulation**, worded so it can never be misread as an account of a query that already happened.
- **Diagnostics can be scoped to an absolute window.** `/diagnostics?since=…&until=…` now validates and applies both bounds to the active and resolved lists, and the page states the window it is showing with a way to clear it. A query's detail page links here with the five minutes either side of that query, which is where the underlying failure text for a SERVFAIL lives.
- **The query log and the dashboard say how far back the history goes.** `GET /api/queries`, `/api/stats` and `/api/stats/timeseries` each carry a `coverage` object: `available_since`, the first second the file can answer for, and `complete`, whether the window you asked for begins inside it. A period that starts before the query log does now says so instead of charting the missing part as zero — which is what a recreate, a retention pass or a fresh install would otherwise look like.
### Removed
- **`/queries`, `/queries/{id}`, `/live` and `/lookup` are gone, and bookmarks to them break.** There is no redirect and no alias: the paths simply stop resolving, and the app shows its not-found page. Everything those pages did is on `/activity`, `/activity/queries/{id}` and `/activity/test`. Three navigation entries collapse into one, "Activity". The API is untouched — `/api/queries`, `/api/queries/{id}`, `/api/queries/live` and `/api/lookup` all answer exactly as before.
- **The Status column, and the block reason on every row.** The reason a query was blocked was repeated on each of a hundred rows and pushed the answer the client saw off the table. Result and Route replace it; the exact rule, the blocklist source and the historical group stay one click away on the query's detail page, which is the only place they were ever readable.
### Changed
- **`GET /api/queries` rows changed shape.** Each row gains `qclass`, `rcode`, `policy_action`, `policy_reason` and `route_kind`, and `block_reason` is gone: the reason a query was blocked is now one of a closed set of values rather than a formatted string. No table column shows it — the reason is read on the query's detail page, and by an API client from `policy_reason` on the row. `blocked`, `cache_hit`, `upstream` and every other existing field are unchanged.
- **Upgrading resets your query history.** The `query_log` table gains the provenance columns below, and `querylog.db` is never migrated (it holds expendable log rows, so a schema change replaces the file instead of upgrading it). On the first start after the upgrade the old file is set aside as `querylog.db.schema-changed-<unix seconds>` and a fresh one is created. Nothing else is touched: `config.db` keeps your configuration and your diagnostics history. The recreate files a resolved `query_log.recreated` diagnostics entry naming the file that was kept and the timestamp the new history begins at, and a new `querylog_meta` table records that coverage start, so the dashboard can say "history is available from ..." instead of charting an empty range as zero. The set-aside file is a working SQLite database and can be deleted once you have decided you do not want it.
- **`logging.query_log_buffer_max` now accepts 1 to 37449, down from 1 to 1000000.** The queued entry carries every new provenance field by value and is about four times as wide as before — 1792 bytes against 432 — so the meaningful bound is bytes rather than entries. The ceiling is computed at compile time from the width of the entry so that the queue's worst case stays within 64 MiB, and it moves whenever that width does. The default of 10000 is unchanged and costs about 17 MiB. A configuration above the new ceiling is rejected at startup with the ceiling in the message.
- **Group and blocklist source names are now capped at 64 bytes.** Both are copied into every query-log row that mentions them, so an unbounded name was an unbounded cost per row. A longer name is rejected as `GroupNameTooLong` or `SourceNameTooLong`.
### Fixed
- **A UDP reply that has to be truncated keeps the answer's RCODE.** When an answer does not fit the client's UDP buffer, nxdns replaces it with an empty reply carrying the TC bit, which tells the client to retry over TCP. That replacement was always built as NOERROR, whatever the answer said — so an oversized NXDOMAIN reached the client as a success, and an EDNS extended RCODE above 15 lost the eight upper bits it needs an OPT record to carry. The truncated reply now carries the full twelve-bit code the answer had, split across the header and the reply's OPT record where the code needs it, and the query-log row records the code the client actually saw. The retry over TCP always returned the right RCODE; this was the UDP answer that preceded it.
## [0.0.8] - 2026-08-21
One constant, chosen from the 0.0.7 field numbers: the checkpoint cadence was the last first-order write cost on the Pi's SD card.
### Changed
- **The query log checkpoints its write-ahead log every 32 MiB instead of every 4 MiB.** Batching the writer in 0.0.7 took the deployed Pi from about 0.5 to 0.281 GiB of writes a day, and about 130 MiB of what is left is checkpoint writeback: SQLite's 1000-page default trips roughly every 40 minutes and rewrites the same hot index and interior pages into `querylog.db` each time. Every read-write connection to `querylog.db` now sets `wal_autocheckpoint` to 8192 pages, which stretches that to roughly five hours and cuts those in-place rewrites about eightfold, for an expected total near 190 MiB a day. The price is durability under power loss or a kernel panic. At `synchronous = NORMAL` a commit does not fsync, so the checkpoint is the only guaranteed durability boundary, and it now sits about five hours of query rows and upstream-history minutes back rather than 40 minutes. Kernel writeback normally makes the real loss far smaller than that, but nothing guarantees it. A process crash or a clean stop still loses nothing that was committed, and the database is never left inconsistent: recovery replays the longest valid prefix of the log. The `querylog.db-wal` file is expected to sit near 32 MiB rather than capped there, since a long-running reader can hold a checkpoint off and let it overshoot, and the daily retention pass still truncates it. `config.db` is unchanged.
## [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
Devices name themselves: the clients table asks the router over reverse DNS instead of waiting for the operator to type every name. The CI container gate also moved from workflow shell into a compiled, tested tool, which fixed a latent temp-directory bug shared with the release tool.
### Added
- **Client names learned over reverse DNS.** A client row that carries no hand-typed name gets one from the network: each tracker flush pass takes up to 16 unnamed rows, builds each address's reverse name, matches it against the declared `forward_zones`, and on a match sends one PTR query to that zone's resolver, storing the answer as a *learned* name. This requires a conditional forward zone covering the LAN's reverse space — for example `168.192.in-addr.arpa` pointed at the router; without one, nothing is sent anywhere. A hand-typed name always wins, learned names never appear in `nxdns export` and are never set by `nxdns import`, and each row refreshes once a day (an hour after a failure), so a rename can show stale for up to 24 hours. The API's `Client` object gains a `learned_name` field and the clients page shows it.
### Changed
- The container CI gate — image build, image-contents assertion against the packaged artifacts, and the startup/shutdown smoke test — moved from workflow shell into `tools/container_check.zig`, compiled and unit-tested by `zig build test` and runnable on a laptop against a local docker daemon. The health probe now runs under a real 60-second deadline (the shell loop's "30 seconds" could stretch past three minutes), and the gate's docker objects carry an ownership label so anything a dead runner leaks is discoverable. The version in CI is parsed from `build.zig.zon` through the zon grammar, once, instead of by two copies of a `sed` regex.
### Fixed
- The release tool's temporary-directory claim was not exclusive: the "create" it relied on succeeds on a directory that already exists, so a stale or concurrent directory could be silently adopted, written into, and deleted on exit. Both the release tool and the new container gate now claim their directories exclusively and retry on collision.
## [0.0.2] - 2026-08-14
Configuration can now be a file that every boot converges to, filtering gains regex rules and honors blocklist exception lines, and two refresh bugs that silently kept stale state are fixed. Note the three breaking changes below if you script against `nxdns import` or run with `--config`.
### Added
- **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the file the sole source of configuration: every boot converges the database to it in one transaction, preserving blocklist downloads, compiled lists and client history, so an unchanged file costs zero downloads and zero writes. Bare `nxdns run` keeps the database (and the web UI) in charge, exactly as before. In file mode the web UI is read-only for configuration and says so; runtime actions (pause, blocklist refresh, certificate reload) stay live. `GET /api/settings` reports which authority governs the process.
- `nxdns import` now refuses a file whose application would delete configuration rows, names the tables and counts, and applies it only with the new `--allow-delete` flag. Additive and edit-in-place imports need no flag.
- **Regex rules.** Rules gain a third kind, `regex`, beside `exact` and `wildcard`, for per-group allow and block patterns such as `^ad[0-9]+-`. The engine is homegrown and linear-time by construction, so no pattern can make matching blow up; backreferences and lookaround do not exist, and a bad pattern is refused at insert time with the limit it hit. Matches appear in `/api/lookup` and the query log as `rule_allow_regex` / `rule_block_regex`. Regex still comes only from you: regex lines in downloaded lists stay counted and skipped.
- **Blocklist exception lines are honored.** An Adblock-Plus `@@||name^` line in a downloaded list now lifts that name — and its subdomains — out of what the attached lists block. Exceptions sit below every rule you wrote: a downloaded list can reopen only a hole another downloaded list dug, never override an operator decision. Each source reports how many it carried.
- **Browser-only lines are counted where you can see them.** Every source now reports how many of its lines nxdns skipped as syntax with no DNS meaning — cosmetic filters, `$`-modifier rules — beside the existing skipped-regex count. Both blocklist tables show the number and the UI explains the difference: a list whose skipped-unsupported count dwarfs its domain count is written for browser extensions, and its DNS or hosts variant will block more. Previously such a list compiled to almost nothing and looked clean.
### Changed
- **Breaking: `nxdns run --config <file>` changed meaning.** It used to seed the database once and then ignore the file; it now makes the file the authority on every boot, which deletes any configuration the file does not declare — including edits made through the web UI since the seed. Before upgrading a unit that carries `--config`: either drop the flag to keep the database in charge, or adopt file mode with the sequence in the upgrade guide. Order matters there: export the file with the NEW binary (stopped).
- **Breaking: 0.0.1 exports are refused by this version.** A 0.0.1 `nxdns export` writes both `.password = ""` and the stored `.password_hash`, and this version refuses a file that carries both. This bites any old export — an adoption file or a configuration backup fed to `nxdns import` alike. Fix an existing export by deleting its `.password = ""` line (keep the `.password_hash` line). Take fresh backups with the new binary.
- **Breaking: the offline password-change recipe changed.** Setting `.password = "new"` together with `.password_hash = ""` is now refused (empty `password_hash` is an explicit "disable authentication", and the two fields cannot both be present). To change the password in the file: set `.password` and delete the `.password_hash` line entirely.
- `nxdns import --force` is renamed `--allow-delete`.
- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence. Use `nxdns import` once, or run in file mode with `--config`.
- The admin UI's internals moved to TypeScript 7 and replaced Tailwind with StyleX and React Aria. The visible change is small: selects are real widgets with working keyboard focus; everything else renders as before.
- The `config.db` schema is a single baseline definition again; numbered migration steps start accumulating at v0.1.
### Fixed
- **A list switching a name between its exact and wildcard forms never took effect.** The compiled-list checksum hashed the exact and wildcard bodies as one unseparated byte stream, so a list carrying `a.example` and the same list carrying `*.a.example` produced the same digest, and the refresh kept the old compiled files. The checksum now separates the bodies. Every source recompiles once on its first refresh after the upgrade; no re-download of unchanged content is forced beyond the refresh's normal fetch.
- **A refresh could store stale skip counts.** When a refresh found the list content unchanged, it wrote the previously stored skip counters back to the database while showing the fresh ones in the UI, and the next restart reverted the numbers to the stale copy. All counters now persist from the fresh compile.
- An Adblock-Plus entry with embedded whitespace (`||good.example bad.example^`) compiled into an entry no query could ever match. Such lines are now counted as unsupported instead.
## [0.0.1] - 2026-08-09
First release. Everything below is new.
### Added
- **Forwarding DNS server.** UDP and TCP listeners with a wire-format parser and
encoder written against RFC 1035 and EDNS(0), a bounded worker model, per-client
rate limiting and a `pause` control that stops filtering without stopping
resolution.
- **Encrypted upstreams.** DNS-over-HTTPS and DNS-over-TLS clients over a pool
that tracks per-upstream health and fails over, with SNI and certificate
verification driven by a per-upstream TLS name.
- **DoH and DoT endpoints.** nxdns also answers as an encrypted resolver, with a
certificate store that reloads on disk changes and through the API, so renewals
do not need a restart.
- **Blocklist filtering.** Subscriptions in hosts, plain-domain and
Adblock-Plus-style formats, compiled into a compact matcher; per-group allow
and block rules with wildcards; safe-search enforcement.
- **Per-client policy groups.** Clients are identified by address and assigned to
groups, so the filtering a device gets depends on which device it is.
- **Local DNS.** Local A/AAAA/CNAME/PTR records and conditional forwarding of
internal zones to another resolver.
- **Cache.** A bounded in-memory cache that respects upstream TTLs and expires
entries rather than serving them stale.
- **Query log.** Queries land in SQLite under a retention policy in both rows and
days, with disk-full self-protection that degrades instead of corrupting, and a
live SSE stream of the same events.
- **Web UI and REST API.** A React single-page admin UI embedded in the binary,
a REST API with a served OpenAPI document, session authentication, API rate
limiting and Prometheus-style `/metrics`.
- **Configuration.** A ZON configuration file seeds the database on first boot;
after that the database is the truth, and `nxdns export` / `nxdns import` move
configuration in and out. `nxdns check` validates a file without starting.
- **Forwarding DNS server.** UDP and TCP listeners with a wire-format parser and encoder written against RFC 1035 and EDNS(0), a bounded worker model, per-client rate limiting and a `pause` control that stops filtering without stopping resolution.
- **Encrypted upstreams.** DNS-over-HTTPS and DNS-over-TLS clients over a pool that tracks per-upstream health and fails over, with SNI and certificate verification driven by a per-upstream TLS name.
- **DoH and DoT endpoints.** nxdns also answers as an encrypted resolver, with a certificate store that reloads on disk changes and through the API, so renewals do not need a restart.
- **Blocklist filtering.** Subscriptions in hosts, plain-domain and Adblock-Plus-style formats, compiled into a compact matcher; per-group allow and block rules with wildcards; safe-search enforcement.
- **Per-client policy groups.** Clients are identified by address and assigned to groups, so the filtering a device gets depends on which device it is.
- **Local DNS.** Local A/AAAA/CNAME/PTR records and conditional forwarding of internal zones to another resolver.
- **Cache.** A bounded in-memory cache that respects upstream TTLs and expires entries rather than serving them stale.
- **Query log.** Queries land in SQLite under a retention policy in both rows and days, with disk-full self-protection that degrades instead of corrupting, and a live SSE stream of the same events.
- **Web UI and REST API.** A React single-page admin UI embedded in the binary, a REST API with a served OpenAPI document, session authentication, API rate limiting and Prometheus-style `/metrics`.
- **Configuration.** A ZON configuration file seeds the database on first boot; after that the database is the truth, and `nxdns export` / `nxdns import` move configuration in and out. `nxdns check` validates a file without starting.
- **CLI.** `run`, `check`, `export`, `import`, `version` and `help`.
- **Packaging.** A hardened systemd unit with a sysusers fragment, and a
`FROM scratch` container image holding the binary, a CA bundle and the licence
files, assembled by a builder stage pinned to `alpine:3.22` by digest. Nothing
from Alpine ships in the published image except that CA bundle.
- **Releases.** Tags publish five assets — static musl tarballs for
`x86_64-linux-musl` and `aarch64-linux-musl`, `IMAGE-DIGEST.txt` naming the
multi-architecture container image by digest, `SHA256SUMS.txt` over those
three, and `SHA256SUMS.txt.asc`, a detached signature over the checksum file.
`zig build dist` and `zig build verify-dist` produce and check the same
artifacts on a laptop.
- **Licensing.** EUPL-1.2, with a `THIRD-PARTY-NOTICES` file in every tarball and
image assembled from a reviewed inventory of what the artifacts contain.
- **Documentation.** A Diátaxis split — tutorial, how-to, reference, explanation —
with drift guards that fail the build when the reference pages fall behind the
code.
- **Packaging.** A hardened systemd unit with a sysusers fragment, and a `FROM scratch` container image holding the binary, a CA bundle and the licence files, assembled by a builder stage pinned to `alpine:3.22` by digest. Nothing from Alpine ships in the published image except that CA bundle.
- **Releases.** Tags publish five assets — static musl tarballs for `x86_64-linux-musl` and `aarch64-linux-musl`, `IMAGE-DIGEST.txt` naming the multi-architecture container image by digest, `SHA256SUMS.txt` over those three, and `SHA256SUMS.txt.asc`, a detached signature over the checksum file. `zig build dist` and `zig build verify-dist` produce and check the same artifacts on a laptop.
- **Licensing.** EUPL-1.2, with a `THIRD-PARTY-NOTICES` file in every tarball and image assembled from a reviewed inventory of what the artifacts contain.
- **Documentation.** A Diátaxis split — tutorial, how-to, reference, explanation — with drift guards that fail the build when the reference pages fall behind the code.
+21 -32
View File
@@ -11,12 +11,9 @@ This directory is an nxdns release for one architecture. It holds:
| `THIRD-PARTY-NOTICES` | Licences of everything compiled or bundled in |
| `INSTALL.md` | This file |
The binary is statically linked against musl and needs nothing installed on the
target host.
The binary is statically linked against musl and needs nothing installed on the target host.
Verify the download before you trust it. `docs/how-to/verify-a-release.md` in
the repository covers where the public key comes from, what fingerprint to
expect, and what the signature does and does not prove.
Verify the download before you trust it. `docs/how-to/verify-a-release.md` in the repository covers where the public key comes from, what fingerprint to expect, and what the signature does and does not prove.
## 1. Install the binary, the user and the unit
@@ -34,17 +31,13 @@ systemctl daemon-reload
mkdir -p -m 0755 /etc/nxdns
```
`nxdns.conf` ships under the name it is installed as, so there is no rename to
get wrong.
`nxdns.conf` ships under the name it is installed as, so there is no rename to get wrong.
Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's
`StateDirectory` and `LogsDirectory` settings make systemd create them on first
start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's `StateDirectory` and `LogsDirectory` settings make systemd create them on first start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
## 2. Write the seed configuration
## 2. Write the configuration
nxdns starts from an empty database only if a configuration file tells it what
to forward to. Write `/etc/nxdns/config.zon`:
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`:
```zon
.{
@@ -54,17 +47,14 @@ to forward to. Write `/etc/nxdns/config.zon`:
}
```
That file holds a password in plain text. Root's umask is 022 on most
distributions, so restrict it as soon as you have written it:
That file holds a password in plain text. Root's umask is 022 on most distributions, so restrict it as soon as you have written it:
```sh
chown root:nxdns /etc/nxdns/config.zon
chmod 0640 /etc/nxdns/config.zon
```
0640 with group `nxdns` rather than 0600: the service runs as `nxdns` and has to
read this file on the first start, and systemd leaves `/etc/nxdns` owned by
root.
0640 with group `nxdns` rather than 0600: the service runs as `nxdns`, and systemd leaves `/etc/nxdns` owned by root. Keep that group read bit for good. Under `run --config` the service reads this file on **every** start, not once, so tightening the mode after the first boot breaks the next restart. Under database authority it is `nxdns import` that reads the file, as whoever runs that command, and a bare `nxdns run` never reads it at all.
Check it before starting the service:
@@ -72,20 +62,23 @@ Check it before starting the service:
nxdns check --config /etc/nxdns/config.zon
```
A good file ends with `OK: no problems found`. Exit 2 means `check` found
something to fix and printed every problem it found. The upstream probe sends a
real query, so this needs working DNS on the host.
A good file ends with `OK: no problems found`. Exit 2 means `check` found something to fix and printed every problem it found. The upstream probe sends a real query, so this needs working DNS on the host.
The file seeds the database once. From the second start onwards it is ignored
and the database is the configuration. The seed's `web.password` is hashed at
import time and the plaintext is never stored, so once you have logged in you
can delete the file:
Load it into the database:
```sh
nxdns import /etc/nxdns/config.zon
```
The packaged unit runs `nxdns run` with no `--config`, so from here the database is the configuration and nothing reads the file again. `web.password` is hashed and the plaintext is never stored, so once you have logged in you can delete the file:
```sh
rm /etc/nxdns/config.zon
```
A kept seed is not a backup. `nxdns export` is.
A kept file is not a backup. `nxdns export` is.
To keep the file as the configuration instead — converged at every start, with the UI refusing configuration edits — do not delete it, and add a drop-in that appends `--config=/etc/nxdns/config.zon` to `ExecStart`. See `docs/how-to/install-with-systemd.md`.
## 3. Start it
@@ -94,9 +87,7 @@ systemctl enable --now nxdns
journalctl -u nxdns -f
```
A healthy start logs a line naming every socket it bound. Port 53 is
privileged, and the unit grants `CAP_NET_BIND_SERVICE` through
`AmbientCapabilities`.
A healthy start logs a line naming every socket it bound. Port 53 is privileged, and the unit grants `CAP_NET_BIND_SERVICE` through `AmbientCapabilities`.
## 4. Confirm it answers
@@ -106,9 +97,7 @@ From another machine on the LAN:
dig @<server-ip> example.com A +short
```
The admin interface is on port 8080 by default; log in with the password from
the seed file. `http://<server-ip>:8080/api/health` reports upstream
availability and disk state without a login.
The admin interface is on port 8080 by default; log in with the password from the configuration file. `http://<server-ip>:8080/api/health` reports upstream availability and disk state without a login.
## More
+112 -108
View File
@@ -1,7 +1,6 @@
# nxdns — Implementation Plan v3.0 (Zig 0.16.0 Stable)
Source of truth for **nxdns**, a self-hosted DNS sinkhole written in Zig 0.16.0 stable.
All stdlib claims in this document are verified against the `0.16.0` tag of the Zig repo (`../zig`).
Source of truth for **nxdns**, a self-hosted DNS sinkhole written in Zig 0.16.0 stable. All stdlib claims in this document are verified against the `0.16.0` tag of the Zig repo (`../zig`).
There is no v1/v2 versioning. Scope is binary: a feature is in scope (and gets built) or out of scope (and does not). "Done" = everything in scope implemented, tested, documented.
@@ -23,17 +22,19 @@ Serves a household LAN (≈220 devices). Portfolio-grade public repo with ext
- DNS server for LAN clients: UDP/53, TCP/53, DoH server, DoT server.
- Upstream resolution: DoH (HTTP/1.1), DoT.
- Local DNS records (A/AAAA/CNAME) + conditional forwarding (zone → designated resolver, plain UDP/TCP allowed).
- Domain filtering: blocklists (hosts/domains/ABP), custom rules (allow/block; exact, parent-walk, wildcard), CNAME uncloaking (depth 8), per-group safe-search rewrite.
- Domain filtering: blocklists (hosts/domains/ABP, including `@@||name^` exception lines), custom rules (allow/block; exact, parent-walk, wildcard, regex), CNAME uncloaking (depth 8), per-group safe-search rewrite.
- DNS caching: positive + negative, in-memory only.
- Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments.
- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, dashboard + time buckets, live SSE stream.
- Web app + REST API: LAN/Tailscale admin UI, optional password auth, OpenAPI schema + CI contract tests.
- Observability: upstream health API + UI, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
- Ops: DB-as-truth config, `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose.
- Ops: config authority chosen by the invocation (database, or a file named by `--config`), `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose.
### 2.2 Out of Scope (permanent scope decisions, not deferrals)
- Regex rules. Wildcards + parent-walk cover the real use cases; regex on the DNS hot path means ReDoS exposure plus an immature dependency or a homegrown engine. Regex lines in blocklists are counted, skipped, and the skip count is surfaced in the UI.
- Regex from downloaded blocklists. A list is other people's code running on the household's DNS, and the engine exists for rules the operator wrote. Regex lines in blocklists stay counted and skipped, and the skip count stays surfaced in the UI. (Operator regex rules themselves are **in** scope as of milestone 21: the ReDoS objection that once ruled them out is answered by `filter/regex.zig`, a homegrown Pike VM whose running time is bounded by program length × name length by construction, with no dependency and no backtracking. It is reached only after every hash and wildcard level has missed, and only on a cache miss.)
- ABP syntax beyond domain anchors and `@@||name^` exceptions. `$dnstype`, `$dnsrewrite`, `$client`, `$denyallow` and every browser modifier stay unsupported and counted; the one tolerated modifier is a `$important` suffix on an exception line, which changes nothing about where that exception lands.
- Partial-segment wildcards (`ads*.example.com`) as a rule kind. The regex kind covers the need without a second globbing dialect.
- DHCP server.
- DNSSEC validation (DO bit passthrough only).
- DoQ (QUIC), HTTP/2 upstream transport.
@@ -74,8 +75,8 @@ Verified: 0.16.0 ships `std.crypto.tls.Client` only. There is no server-side TLS
### 3.5 Config Format + Truth Model (Decision F)
- **DB is truth. Config file format is ZON** (`std.zon` parse + stringify — typed parsing into config structs, exact round-trip, stdlib-maintained, comments supported). No TOML: a third-party parser plus a hand-written serializer is two failure surfaces in the correctness-critical bootstrap/round-trip path, bought for syntax familiarity.
- First start: if DB empty and `/etc/nxdns/config.zon` exists, validate → seed DB. Subsequent starts ignore the file.
- **The invocation picks truth (see the next point). Config file format is ZON** (`std.zon` parse + stringify — typed parsing into config structs, exact round-trip, stdlib-maintained, comments supported). No TOML: a third-party parser plus a hand-written serializer is two failure surfaces in the correctness-critical round-trip path, bought for syntax familiarity.
- The invocation picks the authority, and nothing else does (m20): `nxdns run` makes the database the configuration, `nxdns run --config FILE` makes the file the sole source and refuses the API routes that would edit configuration. There is no seeding and no first-start special case.
- `nxdns export [--out file.zon]` dumps DB state as canonical ZON. `nxdns import <file.zon>` validates + replaces DB contents (`--force` if the DB holds configuration; client rows materialised from traffic do not count, and survive the replacement. First-seen/last-seen are runtime state, not configuration: they follow the address, so an import never restamps a device the DB already knew). Export/import = backup + host migration, **not** upgrades (§3.7).
- No file watcher, no auto-regeneration.
@@ -89,21 +90,21 @@ Two SQLite files with opposite write profiles, isolated from each other:
### 3.7 Upgrades: Auto-Migration (Decision J)
- `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart.
- `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart. Before v0.1 the list holds one step — the baseline of §11.2, edited in place — because nxdns has no installs and a step exists only to reconcile a database somebody already has.
- `querylog.db`: **no migrations.** On schema mismatch: rename aside, recreate fresh.
### 3.8 Blocklist Storage (Decision A)
Blocklist domains are **not** stored in SQLite — they are a cache of re-downloadable remote artifacts, not config or state:
- Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, count, checksum). Wildcard/regex-flavored lines: wildcards go to `<source_id>.wild`; regex lines are counted + skipped (count in metadata → UI).
- Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, counts, checksum). Wildcard/regex/exception-flavored lines: wildcards go to `<source_id>.wild`, ABP exceptions (`@@||name^`) to `<source_id>.allow`; regex lines and browser-syntax lines nxdns cannot translate into a DNS decision are counted + skipped, both counts in metadata → UI. The checksum covers the three bodies in that order, each followed by a separator byte so that moving a name between bodies — an upstream switching `a.example` to `*.a.example` — changes the digest and forces a republish.
- `config.db` keeps source **metadata only** (`blocklist_sources`).
- Startup + post-update: parse files into the immutable in-memory matcher (RCU swap, §9.5).
- Corruption recovery is per-file: checksum mismatch → re-download one list.
### 3.9 Rule Model (Decision B)
Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*` segment patterns, e.g. `*.doubleclick.net`, `ads.*.example.com`). Actions: `allow` | `block`. Kind is an explicit column — the model is extensible without breakage, but regex stays out of scope (§2.2).
Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*` segment patterns, e.g. `*.doubleclick.net`, `ads.*.example.com`), `regex` (the linear-time engine of `filter/regex.zig`, matched unanchored against the whole normalized name). Actions: `allow` | `block`. Kind is an explicit column, so a fourth kind widens one `CHECK` and touches no other table. A regex pattern is stored exactly as written — it is not a name, so it is never lowercased or dot-stripped — and is compiled at both edges: `config/validate.zig` refuses a bad one with the limit it hit, and `filter/rules.zig` compiles it once per snapshot.
### 3.10 Filtering Precedence
@@ -111,11 +112,18 @@ Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*`
2. Exact/parent **block** rules
3. Wildcard allow rules
4. Wildcard block rules
5. Blocklist domains
6. Blocklist wildcards
5. Regex allow rules
6. Regex block rules
7. Blocklist exceptions (`@@||name^`)
8. Blocklist domains
9. Blocklist wildcards
Tie-break at same specificity: **allow wins**.
Each level is checked against the whole candidate chain before the next level is checked against any, which is what makes an allow rule on a parent beat a block rule on the child.
Two positions carry an argument rather than a preference. The regex levels come last among the operator rules because they are the only ones that are not a set lookup or a label walk: a regex runs only once every cheaper level has missed. Blocklist exceptions come below **every** operator level because a downloaded list may cancel what another list blocked and must never cancel what the operator decided — no list can open an allow hole the operator did not open.
### 3.11 Network Posture
- Default web bind: LAN/Tailscale-friendly (non-loopback allowed).
@@ -128,14 +136,14 @@ IPv4 + IPv6 full parity for: client identity, rate limiting, logging, group assi
### 3.13 Filesystem Layout (FHS)
- `/etc/nxdns/config.zon`bootstrap (first start only).
- `/etc/nxdns/config.zon`the declarative source, read only when `run --config` names it. A file no flag names changes nothing.
- `/var/lib/nxdns/config.db`, `/var/lib/nxdns/querylog.db`
- `/var/lib/nxdns/blocklists/*.list|*.wild` (plus `*.raw.tmp|*.list.tmp|*.wild.tmp` during a refresh)
- `/var/lib/nxdns/blocklists/*.list|*.wild|*.allow` (plus `*.raw.tmp|*.list.tmp|*.wild.tmp|*.allow.tmp` during a refresh)
- `/var/log/nxdns/nxdns.log` — only in file output mode; default is stderr → journald.
### 3.14 Frontend Stack (Decision I)
- Vite + React + TypeScript + Tailwind; TanStack Router + TanStack Query. SPA, no SSR.
- Vite + React + TypeScript + StyleX + React Aria; TanStack Router + TanStack Query. SPA, no SSR.
- **Built assets embedded in the binary** at compile time: single self-contained artifact, no asset-path config, no binary/UI skew. `zig build` accepts the dist path; the release pipeline runs `npm run build` first (CI always does).
- Dev-mode flag serves assets from disk so UI iteration needs no Zig rebuild.
@@ -173,7 +181,7 @@ Client DNS Query
Response to client
```
Cross-cutting: `ConfigManager` (bootstrap/import/export/settings), `BlocklistManager` (fetch/compile/swap), `Storage` (two SQLite DBs), `Cache`, `UpstreamHealth`, `DiskMonitor`, `Auth`, `Web API` (REST + SSE + metrics).
Cross-cutting: `ConfigManager` (load/reconcile/import/export/settings), `BlocklistManager` (fetch/compile/swap), `Storage` (two SQLite DBs), `Cache`, `UpstreamHealth`, `DiskMonitor`, `Auth`, `Web API` (REST + SSE + metrics).
---
@@ -203,7 +211,8 @@ src/
filter/ # pure
matcher.zig rules.zig wildcard.zig
parser_hosts.zig parser_domains.zig parser_abp.zig
fetcher.zig compiler.zig # list download -> compiled .list/.wild files
fetcher.zig compiler.zig # list download -> compiled .list/.wild/.allow files
regex.zig # linear-time Pike VM for operator regex rules
safesearch.zig
local/ # pure
@@ -223,7 +232,8 @@ src/
logger.zig retention.zig disk_monitor.zig
config/
model.zig bootstrap.zig import.zig export.zig validate.zig # all ZON via std.zon
model.zig loader.zig reconcile.zig import.zig export.zig # all ZON via std.zon
validate.zig faults.zig
web/
server.zig router.zig auth.zig sse.zig static.zig metrics.zig openapi.zig
@@ -232,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 + Tailwind + 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/
@@ -272,32 +282,27 @@ Walk chain to depth 8; any target hitting block logic → synthesize blocked res
### 7.1 Evaluation
For `{domain, group_id}` (the qtype travels with the query for logging and response synthesis, not
for matching):
For `{domain, group_id}` (the qtype travels with the query for logging and response synthesis, not for matching):
1. Normalize: lowercase, trim trailing dot.
2. Build candidate chain (full, parent1, parent2, …).
3. Explicit rules per §3.10 precedence, evaluated against every candidate in the chain.
4. Group's blocklist domains (hash set over compiled lists), matched against the query name only.
5. Group's blocklist wildcards, matched against every proper parent of the query name.
6. No match → allow.
3. Explicit rules per §3.10 precedence, allow before block at each level: exact rules against every candidate in the chain, then wildcard patterns and then regex patterns against the whole name (both kinds express their own reach, so neither walks the chain).
4. Group's blocklist exceptions (`@@` entries), against every candidate in the chain. They cancel a block a list made and never one a rule made.
5. Group's blocklist domains (hash set over compiled lists), matched against the query name only.
6. Group's blocklist wildcards, matched against every proper parent of the query name.
7. No match → allow.
Blocklist entries do not parent-walk; only rules do (§3.9). ABP `||x.y^` emits both a domain entry
`x.y` and a wildcard entry `x.y`, which together give domain-and-subdomains semantics.
Blocklist *domain* entries do not parent-walk: they are matched against the query name alone. Wildcard entries match every proper parent, and exception entries walk the candidate chain the way rules do (§3.9), so `@@||good.ads.example^` also lifts `y.good.ads.example`. ABP `||x.y^` emits both a domain entry `x.y` and a wildcard entry `x.y`, which together give domain-and-subdomains semantics.
### 7.2 Group Assignment
- Per source IP: (1) exact match in `clients`, (2) longest-prefix match in `client_prefixes` (ties: longer prefix, then priority), (3) `default` group.
- Auto-materialization: first query from an unseen IP inserts a `clients` row (`hand_edited=0`, `first_seen=now`) for UI visibility and stable group assignment. The query log does **not** FK to it (§3.6).
- Materialized clients name themselves: the tracker's flush pass sends one PTR query per unnamed row through the declared forward zones (§6.5), and the answer is runtime state in `learned_name`, never configuration.
- Retention drops `hand_edited=0` clients with no queries in `retention_days`.
### 7.3 Reload
New immutable matcher built from DB + compiled list files → swap under an `std.Io.RwLock` with a
generation counter. Readers take the shared lock for the microseconds of one evaluate; the writer
takes the exclusive lock only for the swap, and the source status table is installed in the same
critical section, so a failed reload publishes neither. (Deliberate deviation from "readers
lock-free": freeing the old snapshot without a lock needs epoch-based reclamation, unjustifiable at
household scale — see specs/milestone-5.md S8.3.)
New immutable matcher built from DB + compiled list files → swap under an `std.Io.RwLock` with a generation counter. Readers take the shared lock for the microseconds of one evaluate; the writer takes the exclusive lock only for the swap, and the source status table is installed in the same critical section, so a failed reload publishes neither. (Deliberate deviation from "readers lock-free": freeing the old snapshot without a lock needs epoch-based reclamation, unjustifiable at household scale — see specs/milestone-5.md S8.3.)
### 7.4 Safe-Search
@@ -320,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.
@@ -341,7 +346,11 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
`journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout` set.
### 11.2 config.db Schema
### 11.2 config.db Schema (v1 baseline)
The DDL below is the live schema, kept byte-identical to `src/storage/config_schema.zig`. `src/storage/migrations.zig` carries it as its one and only step, so a database is at **version 1** or it does not exist.
Until nxdns reaches v0.1 this baseline is **editable**: a schema change edits this section and `config_schema.zig` together and adds no migration step. nxdns has no installs, so there is no database for a step to reconcile. At v0.1 the baseline freezes and every later change becomes an append-only step.
```sql
CREATE TABLE schema_version (version INTEGER NOT NULL);
@@ -357,6 +366,8 @@ CREATE TABLE clients (
id INTEGER PRIMARY KEY,
ip TEXT NOT NULL UNIQUE, -- canonical text form (v4 dotted / v6 RFC 5952)
name TEXT,
learned_name TEXT,
name_attempt_after INTEGER NOT NULL DEFAULT 0,
group_id INTEGER NOT NULL REFERENCES groups(id),
hand_edited INTEGER NOT NULL DEFAULT 0,
first_seen INTEGER NOT NULL,
@@ -374,7 +385,8 @@ CREATE TABLE upstreams (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
priority INTEGER NOT NULL DEFAULT 100,
enabled INTEGER NOT NULL DEFAULT 1
enabled INTEGER NOT NULL DEFAULT 1,
tls_name TEXT NOT NULL DEFAULT '' -- DoT verification name; empty verifies against the url host
);
CREATE TABLE blocklist_sources (
@@ -386,7 +398,9 @@ CREATE TABLE blocklist_sources (
last_updated INTEGER,
domain_count INTEGER NOT NULL DEFAULT 0,
wildcard_count INTEGER NOT NULL DEFAULT 0,
exception_count INTEGER NOT NULL DEFAULT 0,
skipped_regex_count INTEGER NOT NULL DEFAULT 0,
skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
checksum TEXT
);
@@ -400,7 +414,7 @@ CREATE TABLE rules (
id INTEGER PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
pattern TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')),
kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard','regex')),
action TEXT NOT NULL CHECK(action IN ('allow','block')),
created_at INTEGER NOT NULL
);
@@ -421,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
@@ -446,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.
@@ -471,52 +523,17 @@ Periodic delete of rows older than `retention_days`; scheduled checkpoint/VACUUM
## 12. Configuration
### 12.1 Bootstrap ZON Shape
### 12.1 Config ZON Shape
The canonical shape is not duplicated here. It lives in [docs/reference/configuration.md](docs/reference/configuration.md), which is handwritten against `config/model.zig` and only partly guarded (the drift test covers settings-key rows, not the whole shape, so a new collection can go undocumented while the guard stays green), and `nxdns export` emits it. A copy in this document is how §12.1 came to describe an `.upstream.servers` field that never existed and to omit the required `.groups` and `.upstreams` — a sample nobody could load. The skeleton, for orientation only:
```zon
.{
.upstream = .{
.servers = .{ "https://cloudflare-dns.com/dns-query", "tls://dns.google:853" },
.read_timeout_ms = 3000,
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
.rules = .{
.{ .group = "default", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
},
.dns = .{
.bind_ipv4 = "0.0.0.0",
.bind_ipv6 = "::",
.port = 53,
.rate_limit = 1000,
.rate_window_seconds = 60,
},
.blocking = .{ .response = .zero, .ttl = 5 }, // .zero | .nxdomain
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
.web = .{
.enabled = true,
.bind = "0.0.0.0",
.port = 8080,
.password = "", // empty => auth disabled
.session_ttl_hours = 24,
.api_rate_limit_per_min = 300,
.sse_max_connections_per_ip = 3,
},
.doh_server = .{ .enabled = false, .bind = "0.0.0.0", .port = 443,
.cert_path = "/etc/nxdns/cert.pem", .key_path = "/etc/nxdns/key.pem" },
.dot_server = .{ .enabled = false, .bind = "0.0.0.0", .port = 853,
.cert_path = "/etc/nxdns/cert.pem", .key_path = "/etc/nxdns/key.pem" },
.edns = .{ .ecs_mode = .strip }, // .strip | .forward
.local_records = .{ .{ .name = "nas.lan", .rtype = .A, .value = "192.168.1.10" } },
.forward_zones = .{ .{ .zone = "lan.home", .resolver = "udp://192.168.1.1:53" } },
.logging = .{
.level = .info,
.retention_days = 30,
.query_log_buffer_max = 10000,
.hide_domains = false,
.hide_client_ips = false,
.output = .stderr, // .stderr | .syslog | .file
.file_path = "/var/log/nxdns/nxdns.log",
.max_size_mb = 50,
.max_files = 5,
},
.disk = .{ .min_free_mb = 200, .warn_free_mb = 500 },
.blocklist_update = .{ .enabled = true, .interval_hours = 24 },
}
```
@@ -543,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
@@ -576,54 +593,43 @@ Requirements: responsive desktop/mobile; route loaders for initial fetch; TanSta
## 16. Implementation Order
### Phase 0 — Build Baseline
Scaffold tree; `build.zig` with 0.16 assertion, musl targets, vendored sqlite3 + mbedtls compiling; version plumbing; Gitea Actions workflows (test, integration, fuzz smoke, OpenAPI lint, frontend build).
Exit: cross-compiled hello-world linking both C deps on both targets; CI green.
Scaffold tree; `build.zig` with 0.16 assertion, musl targets, vendored sqlite3 + mbedtls compiling; version plumbing; Gitea Actions workflows (test, integration, fuzz smoke, OpenAPI lint, frontend build). Exit: cross-compiled hello-world linking both C deps on both targets; CI green.
### Phase 1 — Platform Layer
`platform/address.zig` (v4/v6 parity + canonical keys); `platform/tls_client.zig` (deadlines, error classes); `platform/tls_server.zig` (mbedTLS handshake → `std.Io.Reader`/`Writer`).
Exit: UDP echo over `std.Io`; TLS client handshake against a real host; mbedTLS server terminating a loopback TLS connection.
`platform/address.zig` (v4/v6 parity + canonical keys); `platform/tls_client.zig` (deadlines, error classes); `platform/tls_server.zig` (mbedTLS handshake → `std.Io.Reader`/`Writer`). Exit: UDP echo over `std.Io`; TLS client handshake against a real host; mbedTLS server terminating a loopback TLS connection.
### Phase 2 — DNS Core
Types/header/name/question/record/packet; parser + encoder tests + fuzz target; EDNS + DO passthrough.
Exit: unit + fuzz smoke pass.
Types/header/name/question/record/packet; parser + encoder tests + fuzz target; EDNS + DO passthrough. Exit: unit + fuzz smoke pass.
### Phase 3 — Resolver Transport
UDP server, TCP server, DoH + DoT upstream clients, pool + failover/backoff + health.
Exit: A/AAAA forwarding over UDP + TCP; health populated.
UDP server, TCP server, DoH + DoT upstream clients, pool + failover/backoff + health. Exit: A/AAAA forwarding over UDP + TCP; health populated.
### Phase 4 — Storage + Config
SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON bootstrap + import/export; `nxdns check`.
Exit: first start seeds DB from ZON; export → import round-trips byte-stable.
SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON loading + import/export; `nxdns check`. Exit: export → import round-trips byte-stable. (The ZON bootstrap this phase shipped was replaced in m20 by the two authority modes above.)
### Phase 5 — Filtering + Local DNS
Rule matcher (exact/parent/wildcard); blocklist parsers → compiled file format; fetcher + scheduled update; RCU swap; per-group safe-search; local records; forward zones.
Exit: precedence table validated by tests; local zone answers + conditional forwards work.
Rule matcher (exact/parent/wildcard; `regex` added in m21); blocklist parsers → compiled file format; fetcher + scheduled update; RCU swap; per-group safe-search; local records; forward zones. Exit: precedence table validated by tests; local zone answers + conditional forwards work.
### Phase 6 — Cache + Rate Limit + Logging + Disk Monitor
TTL cache + reconstruction; v4/v6 rate limiter; async query logger + backpressure + retention; DiskMonitor + rotation + error-log dedup.
Exit: disk thresholds trigger degradation + drop counters in integration test.
TTL cache + reconstruction; v4/v6 rate limiter; async query logger + backpressure + retention; DiskMonitor + rotation + error-log dedup. Exit: disk thresholds trigger degradation + drop counters in integration test.
### Phase 7 — Handler Integration
Full pipeline composition; CNAME uncloaking; pause/resume.
Exit: end-to-end DNS flow with blocking, local records, cache, failover.
Full pipeline composition; CNAME uncloaking; pause/resume. Exit: end-to-end DNS flow with blocking, local records, cache, failover.
### Phase 8 — Web / API / SSE / Auth / Metrics
HTTP server + router; handlers; SSE; optional auth; API rate limiting; `/metrics`; OpenAPI served + contract tests; embedded frontend + dev-mode disk serving.
Exit: frontend fully drives config and operations; contract tests green.
HTTP server + router; handlers; SSE; optional auth; API rate limiting; `/metrics`; OpenAPI served + contract tests; embedded frontend + dev-mode disk serving. Exit: frontend fully drives config and operations; contract tests green.
### Phase 9 — Local DoH/DoT Endpoints
DoH server + DoT server on `platform/tls_server.zig`; cert watcher + reload.
Exit: LAN client resolves via DoH and DoT against local certs.
DoH server + DoT server on `platform/tls_server.zig`; cert watcher + reload. Exit: LAN client resolves via DoH and DoT against local certs.
### Phase 10 — Packaging + Ops + Docs
systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/var/lib/nxdns` + optional `/var/log/nxdns`); Dockerfile + compose (53/udp+tcp, 8080; mounts `/etc/nxdns`, `/var/lib/nxdns`); operator/architecture/config-reference/API docs.
Exit: documented deployment works end-to-end on the Pi 5.
systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/var/lib/nxdns` + optional `/var/log/nxdns`); Dockerfile + compose (53/udp+tcp, 8080; mounts `/etc/nxdns`, `/var/lib/nxdns`); operator/architecture/config-reference/API docs. Exit: documented deployment works end-to-end on the Pi 5.
---
## 17. Testing Strategy
- **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON bootstrap + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade).
- **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON loading + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade).
- **Fuzz**: DNS parser malformed-packet fuzzing; blocklist parser fuzzing.
- **Integration**: UDP/TCP query path; blocked path; allow-over-block; wildcard precedence; CNAME uncloaking block; local records + forward zones; upstream failover/backoff/health; disk-full degradation; querylog.db corruption recovery; API CRUD; auth on/off; SSE; contract tests.
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log.
@@ -652,9 +658,7 @@ Exit: documented deployment works end-to-end on the Pi 5.
## 20. Publication
The project publishes released binaries and container images from its own Gitea
instance. Building from source stays fully supported and documented; it is no
longer the only path.
The project publishes released binaries and container images from its own Gitea instance. Building from source stays fully supported and documented; it is no longer the only path.
- **Trigger.** Pushing an annotated, GPG-signed tag `vX.Y.Z` to `git.mial.net/mokhtar/nxdns`. Nothing else publishes. Pre-release tags are rejected.
- **Version.** The tag is authoritative. `build.zig.zon`'s `.version` must equal the tag, and the packaging gate asserts it. Nowhere else stores a version.
@@ -697,7 +701,7 @@ longer the only path.
| # | Decision |
|---|----------|
| A | Blocklists compile to flat files under `/var/lib/nxdns/blocklists/`; DB stores source metadata only |
| B | Rule kinds: exact, parent-walk, wildcard. Regex permanently out of scope |
| B | Rule kinds: exact, parent-walk, wildcard, regex (m21, own linear-time engine). Regex *from downloaded lists* stays out of scope |
| C | In scope: local DoH/DoT server, local records, conditional forwarding. Out: HTTP/2, DoQ, DHCP, DNSSEC, clustering |
| D | mbedTLS (vendored) terminates server TLS; stdlib TLS for upstream client |
| E | `std.Io` injected everywhere; `Threaded` backend (io_uring flag dropped in m11 — Evented networking is stubbed at 0.16.0); no custom thread pool |
+43 -79
View File
@@ -1,47 +1,40 @@
# nxdns
A self-hosted DNS sinkhole for a household LAN, written in Zig 0.16. One
static musl binary, SQLite for state, a Raspberry Pi 5 as the reference
target. It answers your network's DNS, blocks what you tell it to, and shows
you what asked for what.
nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and forwards the rest over an encrypted connection.
```mermaid
flowchart LR
devices["Your devices"] -- "DNS query" --> nxdns["nxdns"]
nxdns -- "answer" --> devices
nxdns -- "allowed" --> upstream["Upstream resolvers<br/>DoH / DoT"]
upstream -- "answer" --> nxdns
nxdns -- "blocked" --> sink["0.0.0.0 / NXDOMAIN"]
```
One static Zig binary. SQLite holds the state and the query log, which the web UI, the REST API and `/metrics` read.
## Features
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow
and block rules with wildcard support (`*.example.com`)
- Per-client policy groups: different filtering for the kids' tablet and
your workstation
- Blocklist filtering: subscribe to hosts, domain and Adblock Plus lists — whose `@@` exception lines are honoured — plus your own allow and block rules, exact, wildcard (`*.example.com`) or regular expression
- Two configuration modes: a database the web UI edits, or a ZON file you keep in git and converge onto at every start
- Per-client policy groups: different filtering for the kids' tablet and your workstation
- Local DNS records and conditional forwarding for internal zones
- Encrypted upstreams: DNS-over-HTTPS and DNS-over-TLS with failover
- Built-in DoH and DoT server endpoints, with certificate hot-reload
- Bounded in-memory DNS cache with TTL-respecting expiry
- Query log with retention limits, live-streamed over SSE
- Web UI (embedded in the binary) and a REST API with a served OpenAPI spec
- Prometheus-style `/metrics`, per-client rate limiting, disk-full
self-protection
- Prometheus-style `/metrics`, per-client rate limiting, disk-full self-protection
## Install
**No release exists yet.** This repository has no tags, nothing has been
published to <https://git.mial.net/mokhtar/nxdns/releases>, and no container
image has been pushed. Every release URL on this page and in the how-to guides
is a 404 today, and `docker pull` finds nothing. Until the first tag ships,
building from source is the only way to get nxdns.
**No release exists yet.** This repository has no tags, nothing has been published to <https://git.mial.net/mokhtar/nxdns/releases>, and no container image has been pushed. Every release URL on this page and in the how-to guides is a 404 today, and `docker pull` finds nothing. Until the first tag ships, building from source is the only way to get nxdns.
What a tag will publish, once one exists: five assets — two static musl
tarballs (`nxdns-<version>-x86_64-linux-musl.tar.gz`,
`nxdns-<version>-aarch64-linux-musl.tar.gz`), `IMAGE-DIGEST.txt` naming the
multi-architecture container image by digest, `SHA256SUMS.txt` covering those
three files, and `SHA256SUMS.txt.asc`, a detached OpenPGP signature over the
checksum file. Verify what you downloaded before you run it:
[docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md), which also
says what that signature does and does not prove.
What a tag will publish, once one exists: five assets — two static musl tarballs (`nxdns-<version>-x86_64-linux-musl.tar.gz`, `nxdns-<version>-aarch64-linux-musl.tar.gz`), `IMAGE-DIGEST.txt` naming the multi-architecture container image by digest, `SHA256SUMS.txt` covering those three files, and `SHA256SUMS.txt.asc`, a detached OpenPGP signature over the checksum file. Verify what you downloaded before you run it: [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md), which also says what that signature does and does not prove.
## Quickstart (docker compose)
Seed a minimal configuration and start the published image. This is what the
first release will make possible; it does not work today, because there is no
image in the registry to pull:
Write a minimal configuration and start the published image. This is what the first release will make possible; it does not work today, because there is no image in the registry to pull:
```sh
cd deploy/docker
@@ -56,81 +49,52 @@ EOF
NXDNS_VERSION=<version> docker compose up -d
```
The compose file defaults to `:latest`; pin a version for anything you intend
to keep running. To run it before a release exists, build the image yourself and
name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md)
describes. DNS is on port 53, the web UI on <http://localhost:8080>. The config
file seeds the database on first boot only; from then on the database is the
truth and changes go through the UI, the API, or `nxdns export` /
`nxdns import`. Full install instructions, including the systemd path and
the Pi 5 recipe, are in
[docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
The compose file defaults to `:latest`; pin a version for anything you intend to keep running. To run it before a release exists, build the image yourself and name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) describes. DNS is on port 53, the web UI on <http://localhost:8080>.
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes that file the configuration: every start reconciles the database onto it, and the UI refuses configuration edits. Edit the file and restart to change anything. Drop the `command:` line to run bare `nxdns run` instead, where the database is the configuration and changes go through the UI, the API, or `nxdns export` / `nxdns import` — the packaged systemd unit does that. Which mode is live is printed at every start (`authority: database` / `authority: file (<path>)`); see [docs/explanation/configuration-model.md](docs/explanation/configuration-model.md).
Full install instructions, including the systemd path and the Pi 5 recipe, are in [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
## Building from source
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`.
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:
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.
The version comes from `build.zig.zon` because `verify-dist` asserts the two agree; a tag sets both.
That is not a claim that your tarball will hash the same as a published one.
Nothing in this project measures whether two builds of the same commit on two
different machines land on the same bytes, so no document here describes the
build as reproducible. The gate that would settle it is a recorded deferral —
`specs/milestone-14.md` ruling 12 — and
[docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) explains what
a matching or differing hash is worth in the meantime.
That is not a claim that your tarball will hash the same as a published one. Nothing in this project measures whether two builds of the same commit on two different machines land on the same bytes, so no document here describes the build as reproducible. The gate that would settle it is a recorded deferral — `specs/milestone-14.md` ruling 12 — and [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) explains what a matching or differing hash is worth in the meantime.
## Documentation
Start at [docs/README.md](docs/README.md), which splits the documentation
into a tutorial, how-to guides, reference and explanation.
Start at [docs/README.md](docs/README.md), which splits the documentation into a tutorial, how-to guides, reference and explanation.
- [docs/tutorial/first-run.md](docs/tutorial/first-run.md) — build it, resolve
a name, block a domain, on a scratch directory
- [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) —
a real install, including the Raspberry Pi 5
- [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) — checking
the hashes and the signature before you install
- [docs/reference/configuration.md](docs/reference/configuration.md) — every
configuration field
- [docs/tutorial/first-run.md](docs/tutorial/first-run.md) — build it, resolve a name, block a domain, on a scratch directory
- [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) — a real install, including the Raspberry Pi 5
- [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) — checking the hashes and the signature before you install
- [docs/reference/configuration.md](docs/reference/configuration.md) — every configuration field
- [docs/reference/api.md](docs/reference/api.md) — REST API, auth and SSE
- [docs/reference/cli.md](docs/reference/cli.md) — subcommands, flags and exit
codes
- [docs/explanation/architecture.md](docs/explanation/architecture.md) — module
map and design
- [PLAN.md](PLAN.md) and [specs/](specs/) — scope, design decisions and
per-milestone contracts
- [docs/reference/cli.md](docs/reference/cli.md) — subcommands, flags and exit codes
- [docs/explanation/architecture.md](docs/explanation/architecture.md) — module map and design
- [PLAN.md](PLAN.md) and [specs/](specs/) — scope, design decisions and per-milestone contracts
## Licence
Copyright (c) 2026 Mokhtar Mial. nxdns is licensed under the European Union
Public Licence v. 1.2 (`EUPL-1.2`); the full text is in [LICENSE](LICENSE).
Copyright (c) 2026 Mokhtar Mial. nxdns is licensed under the European Union Public Licence v. 1.2 (`EUPL-1.2`); the full text is in [LICENSE](LICENSE).
Every released tarball and image carries a `THIRD-PARTY-NOTICES` file assembled
from the reviewed inventory in [licenses/](licenses/), which covers what the
artifacts actually contain: musl, the Zig runtime, SQLite, Mbed TLS and its
vendored Everest and p256-m code, and the JavaScript and CSS bundled into the
admin UI.
Every released tarball and image carries a `THIRD-PARTY-NOTICES` file assembled from the reviewed inventory in [licenses/](licenses/), which covers what the artifacts actually contain: musl, the Zig runtime, SQLite, Mbed TLS and its vendored Everest and p256-m code, and the JavaScript and CSS bundled into the admin UI.
@@ -1,3 +1,4 @@
dist/
dist-placeholder/
package-lock.json
dist-sourcemap/

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 303 B

View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,5 +1,5 @@
{
"name": "nxdns-web",
"name": "nxdns-admin",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "vite",
"build": "vite build && node scripts/stamp-dist.mjs",
"build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs",
"typecheck": "tsc -b",
"lint": "oxlint src vite.config.ts",
"format": "prettier --write .",
@@ -25,13 +25,15 @@
"trailingComma": "all"
},
"dependencies": {
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
"react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@stylexjs/unplugin": "0.19.0",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/node": "26.1.1",
@@ -41,8 +43,7 @@
"jsdom": "29.1.1",
"oxlint": "1.75.0",
"prettier": "3.9.6",
"tailwindcss": "4.3.3",
"typescript": "6.0.3",
"typescript": "7.0.2",
"vite": "8.1.5",
"vitest": "4.1.10"
}

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`);
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
// Every rule in the built stylesheet must sit inside a cascade layer
// (milestone 23). Unlayered author CSS outranks every layer whatever its
// selector says, so a single unlayered rule silently beats the StyleX atomic
// rules it was written to sit under. That failure renders wrong and passes
// every other gate: no test asserts computed style, and the bundler is happy.
// It also checks the layer ORDER, which is the invariant that actually matters:
// a later layer beats an earlier one, so `reset` has to be declared first.
//
// What it does not catch, so nobody reads more into a pass than is there: an
// unlayered rule that sets only custom properties is allowed, because StyleX
// emits its token `:root` block exactly that way and this cannot tell that
// block from an override of it; a declaration value containing `@layer` or a
// 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 admin/ as part of `npm run build`.
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
const sheets = readdirSync(distDir).filter((name) => name.endsWith(".css"));
if (sheets.length === 0) {
console.error("assert-css-layers: no stylesheet in dist/assets — did the build emit one?");
process.exit(1);
}
// At-rules that describe a resource or a name rather than styling an element.
// They carry no cascade priority against a layer, so being outside one is
// correct, and StyleX emits `@property` for its custom properties.
const unlayerable = String.raw`@(?:layer|property|keyframes|font-face|counter-style|charset|import)`;
/** Strip comments, then every balanced block and statement the rule above allows. */
function outsideLayers(css) {
let rest = css.replace(/\/\*[\s\S]*?\*\//g, "");
for (;;) {
const at = rest.search(new RegExp(`${unlayerable}[^{;]*\\{`));
if (at === -1) break;
let depth = 0;
let end = rest.indexOf("{", at);
for (let i = end; i < rest.length; i += 1) {
if (rest[i] === "{") depth += 1;
else if (rest[i] === "}") {
depth -= 1;
if (depth === 0) {
end = i;
break;
}
}
}
rest = rest.slice(0, at) + rest.slice(end + 1);
}
return rest.replace(new RegExp(`${unlayerable}[^;{}]*;`, "g"), "");
}
/**
* A rule that only sets custom properties styles nothing on its own — StyleX
* emits its token `:root` block that way, ahead of its layers, and a variable
* is consumed through `var()` rather than competing with a layered rule.
*/
function stylesSomething(body) {
return body
.split(";")
.map((declaration) => declaration.trim())
.some((declaration) => declaration.length > 0 && !declaration.startsWith("--"));
}
/**
* Layer names in the order their position is fixed, which is where each name is
* first mentioned — a later block under an already-named layer does not move it.
*/
function layerOrder(css) {
const seen = [];
for (const [, names] of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@layer\s+([^{;]+)[{;]/g)) {
for (const name of names.split(",")) {
const trimmed = name.trim();
if (trimmed.length > 0 && !seen.includes(trimmed)) seen.push(trimmed);
}
}
return seen;
}
let failed = false;
for (const sheet of sheets) {
const css = readFileSync(join(distDir, sheet), "utf8");
// Order is the whole point: a later layer wins, so the reset has to be first.
const order = layerOrder(css);
if (order.length > 0 && order[0] !== "reset") {
console.error(
`assert-css-layers: ${sheet} declares layers in the order ${order.join(", ")}` +
`'reset' must come first or it outranks the StyleX rules written against it.`,
);
failed = true;
}
const leftover = outsideLayers(css);
for (const [, selector, body] of leftover.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!stylesSomething(body)) continue;
console.error(
`assert-css-layers: ${sheet} styles elements outside every @layer:\n` +
` ${selector.trim().slice(0, 80)} { ${body.trim().slice(0, 60)} … }`,
);
failed = true;
break;
}
}
if (failed) {
console.error("Wrap it in a layer declared before StyleX's, as admin/src/styles.css does.");
process.exit(1);
}
console.log(
`every rule in ${sheets.length === 1 ? "the stylesheet" : `${sheets.length} stylesheets`} sits inside a cascade layer`,
);
@@ -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.
@@ -87,8 +87,8 @@ test("429 login shows a ticking countdown and keeps submit disabled until it end
test("safeRedirect only allows same-origin absolute paths", () => {
expect(safeRedirect(undefined)).toBe("/");
expect(safeRedirect("/queries")).toBe("/queries");
expect(safeRedirect("/queries?x=1")).toBe("/queries?x=1");
expect(safeRedirect("/activity")).toBe("/activity");
expect(safeRedirect("/activity?x=1")).toBe("/activity?x=1");
expect(safeRedirect("//evil.example")).toBe("/");
expect(safeRedirect("https://evil.example")).toBe("/");
expect(safeRedirect("/\\evil.example")).toBe("/");
@@ -1,8 +1,57 @@
import { useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
/** Login renders outside AppShell, so it paints the page ground itself. */
page: {
display: "flex",
minHeight: "100dvh",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.surface,
color: colors.text,
padding: "1rem",
},
card: {
width: "100%",
maxWidth: "24rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
probing: {
marginTop: "1rem",
color: colors.textMuted,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
submit: {
width: "100%",
},
error: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
});
export function safeRedirect(raw: string | undefined): string {
if (raw === undefined) return "/";
@@ -76,15 +125,15 @@ export default function LoginPage() {
}
return (
<main className="flex min-h-dvh items-center justify-center bg-zinc-50 p-4 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<section className="w-full max-w-sm">
<h1 className="text-2xl font-semibold">nxdns</h1>
<main {...stylex.props(styles.page)}>
<section {...stylex.props(styles.card)}>
<h1 {...stylex.props(styles.heading)}>nxdns</h1>
{authRequired !== true ? (
<p className="mt-4 text-zinc-500">Checking whether a password is required</p>
<p {...stylex.props(styles.probing)}>Checking whether a password is required</p>
) : (
<form onSubmit={onSubmit} className="mt-6 space-y-4">
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div>
<label htmlFor="password" className="block text-sm font-medium">
<label htmlFor="password" {...stylex.props(styles.fieldLabel)}>
Password
</label>
<input
@@ -95,20 +144,20 @@ export default function LoginPage() {
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<button
type="submit"
disabled={busy || lockedOut}
className={`w-full ${largePrimaryButtonClass}`}
{...stylex.props(shared.largePrimaryButton, styles.submit, shared.focusRing)}
>
{busy ? "Logging in…" : "Log in"}
</button>
</form>
)}
{error !== null && (
<p role="alert" className="mt-4 text-sm text-red-600 dark:text-red-400">
<p role="alert" {...stylex.props(styles.error)}>
{errorMessage(error, remaining)}
</p>
)}
@@ -0,0 +1,312 @@
import { 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 { QueryDetail } from "@/lib/types";
import { provenance } from "@/features/queries/provenanceFixture";
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
return { id, ...provenance(sections) };
}
let responses: Record<string, unknown>;
beforeEach(() => {
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const payload = responses[String(input)];
if (payload === undefined) {
return new Response(JSON.stringify({ error: "no such query" }), {
status: 404,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => vi.unstubAllGlobals());
function renderDetail(id: number, search = "") {
const queryClient = createQueryClient();
const router = createAppRouter(
createMemoryHistory({ initialEntries: [`/activity/queries/${id}${search}`] }),
queryClient,
);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
/** The search parameters a link carries, so an assertion states them by name. */
function hrefSearch(link: HTMLElement): Record<string, string> {
const query = link.getAttribute("href")?.split("?")[1] ?? "";
return Object.fromEntries(new URLSearchParams(query));
}
/** The value beside a term, so a section's facts are read as pairs. */
function factValue(label: string): string {
const term = screen.getByText(label);
const value = term.nextElementSibling;
return value?.textContent ?? "";
}
/** The line under the domain, which is what a page is read as at a glance. */
function subtitle(domain: string): string {
const heading = screen.getByRole("heading", { name: domain });
return heading.nextElementSibling?.textContent ?? "";
}
test("a blocked query explains itself in the six sections, in pipeline order", async () => {
responses["/api/queries/42"] = detail(42, {
request: { time: 1_700_000_000, domain: "ads.example", client: "192.0.2.11", qtype: 28, qclass: 1 },
group: { id: 3, name: "kids" },
policy: {
action: "block",
reason: "blocklist_wildcard",
matched: "||tracker.example^",
source_id: 5,
source_name: "StevenBlack",
},
rewrites: { cname_target: "cdn.tracker.example" },
route: { kind: "blocked", upstream: "" },
response: { rcode: 0, duration_us: 1234 },
});
renderDetail(42);
await screen.findByRole("heading", { name: "ads.example" });
const headings = screen.getAllByRole("heading", { level: 2 }).map((node) => node.textContent);
expect(headings).toEqual(["Request", "Group", "Policy", "Rewrites", "Route", "Response", "Related"]);
expect(factValue("Client")).toBe("192.0.2.11");
expect(factValue("Type")).toBe("AAAA");
expect(factValue("Class")).toBe("IN (1)");
expect(factValue("Name")).toBe("kids");
expect(factValue("Id")).toBe("3");
expect(factValue("Decision")).toBe("Blocked");
expect(factValue("Reason")).toBe("Blocklist (wildcard)");
expect(factValue("Matched")).toBe("||tracker.example^");
expect(factValue("Blocklist")).toBe("StevenBlack (#5)");
expect(factValue("CNAME target")).toBe("cdn.tracker.example");
expect(factValue("Answered by")).toBe("Blocked locally");
expect(factValue("Upstream")).toBe("No upstream exchange");
expect(factValue("Result")).toBe("NOERROR (0)");
expect(factValue("Took")).toBe("1.2 ms");
// NOERROR is the ordinary case and adds nothing to the verdict.
expect(subtitle("ads.example")).toMatch(/ — Blocked$/);
});
test("an upstream SERVFAIL names the resolver that failed and the code the client saw", async () => {
responses["/api/queries/7"] = detail(7, {
request: { domain: "news.example" },
route: { kind: "upstream", upstream: "https://dns.example/dns-query" },
response: { rcode: 2, duration_us: null },
});
renderDetail(7);
await screen.findByRole("heading", { name: "news.example" });
expect(factValue("Answered by")).toBe("Upstream resolver");
expect(factValue("Upstream")).toBe("https://dns.example/dns-query");
expect(factValue("Result")).toBe("SERVFAIL (2)");
expect(factValue("Took")).toBe("Not measured");
// The policy allowed the query; the client still got nothing, and the
// headline has to say so rather than reading as a success.
expect(subtitle("news.example")).toMatch(/ — Allowed — SERVFAIL \(2\)$/);
});
test("a forward-zone answer says the matcher never ran, not that nothing matched", async () => {
responses["/api/queries/14"] = detail(14, {
request: { domain: "nas.lan.home" },
policy: { action: "allow", reason: "forward_zone", matched: "" },
route: { kind: "forward_zone", forward_zone: "lan.home", upstream: "udp://192.168.1.1:53" },
});
renderDetail(14);
await screen.findByRole("heading", { name: "nas.lan.home" });
expect(factValue("Reason")).toBe("Forward zone");
expect(factValue("Matched")).toBe("The matcher never ran");
});
test("a query the matcher did evaluate keeps the honest empty verdict", async () => {
responses["/api/queries/15"] = detail(15, {
policy: { action: "allow", reason: "no_match", matched: "" },
});
renderDetail(15);
await screen.findByRole("heading", { name: "example.com" });
expect(factValue("Reason")).toBe("No match");
expect(factValue("Matched")).toBe("Nothing matched");
});
test("empty text fields read as absent facts, never as blank values", async () => {
responses["/api/queries/8"] = detail(8, {
group: { id: null, name: "" },
policy: { action: "not_evaluated", reason: "paused", matched: "", source_id: null, source_name: "" },
route: { kind: "upstream", forward_zone: "", upstream: "udp://9.9.9.9:53" },
});
renderDetail(8);
await screen.findByRole("heading", { name: "example.com" });
expect(factValue("Name")).toBe("No group recorded");
expect(factValue("Matched")).toBe("The matcher never ran");
expect(factValue("Blocklist")).toBe("Not a blocklist decision");
expect(factValue("Safe search")).toBe("No rewrite");
expect(factValue("Reason")).toBe("Filtering paused");
});
test("a log with hidden domains renders the server's marker, with nothing invented around it", async () => {
responses["/api/queries/9"] = detail(9, {
request: { domain: "hidden" },
policy: { action: "block", reason: "blocklist_domain", matched: "hidden", source_name: "StevenBlack" },
rewrites: { cname_target: "hidden", safe_search_target: "hidden" },
route: { kind: "blocked", upstream: "" },
});
renderDetail(9);
await screen.findByRole("heading", { name: "hidden" });
expect(factValue("Domain")).toBe("hidden");
expect(factValue("Matched")).toBe("hidden");
expect(factValue("CNAME target")).toBe("hidden");
expect(factValue("Safe search")).toBe("hidden");
// The client is governed by its own flag and stays visible here.
expect(factValue("Client")).toBe("192.0.2.10");
});
test("the related actions carry absolute bounds around the query, and the domain into the simulation", async () => {
responses["/api/queries/11"] = detail(11, {
request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
});
renderDetail(11);
await screen.findByRole("heading", { name: "shop.example" });
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
expect(
within(related)
.getByRole("link", { name: /Test this domain/ })
.getAttribute("href"),
).toBe("/activity/test?domain=shop.example");
// No origin bound at all: five minutes either side of the query itself.
expect(hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }))).toEqual({
mode: "history",
domain: "shop.example",
since: "1699999700",
until: "1700000300",
});
expect(hrefSearch(within(related).getByRole("link", { name: "All activity from this client" }))).toEqual({
mode: "history",
client: "192.0.2.12",
since: "1699999700",
until: "1700000300",
});
// The diagnostics window is the query's own moment, never the origin's.
expect(hrefSearch(within(related).getByRole("link", { name: /Diagnostics around/ }))).toEqual({
since: "1699999700",
until: "1700000300",
});
});
test("an origin bound wins over the default window, one bound at a time", async () => {
responses["/api/queries/17"] = detail(17, {
request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
});
renderDetail(17, "?mode=history&since=1600000000");
await screen.findByRole("heading", { name: "shop.example" });
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
expect(link["since"]).toBe("1600000000");
expect(link["until"]).toBe("1700000300");
});
test("both origin bounds carry through untouched", async () => {
responses["/api/queries/18"] = detail(18, { request: { time: 1_700_000_000, domain: "shop.example" } });
renderDetail(18, "?mode=history&since=1600000000&until=1600000060");
await screen.findByRole("heading", { name: "shop.example" });
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
expect(link["since"]).toBe("1600000000");
expect(link["until"]).toBe("1600000060");
});
test("the back link restores the investigation the reader came from", async () => {
responses["/api/queries/19"] = detail(19, { request: { domain: "shop.example" } });
renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");
await screen.findByRole("heading", { name: "shop.example" });
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
mode: "history",
domain: "shop",
since: "1600000000",
blocked: "true",
});
});
function clientList(client: { ip: string; name: string; learned_name: string }) {
return {
clients: [
{
id: 1,
group_id: 1,
group: "default",
hand_edited: client.name !== "",
first_seen: 1_700_000_000,
last_seen: 1_700_000_100,
...client,
},
],
};
}
/** The related section, whose text is read whole because it is prose, not facts. */
async function relatedText(expected: string) {
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
await waitFor(() => expect(related.textContent?.replace(/\s+/g, " ")).toContain(expected));
}
test("the record keeps the address the query came from, and Related carries the name it has now", async () => {
responses["/api/queries/12"] = detail(12, { request: { domain: "shop.example", client: "192.0.2.12" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" });
renderDetail(12);
await screen.findByRole("heading", { name: "shop.example" });
await relatedText("The client list currently names 192.0.2.12 “Kids iPad”.");
expect(factValue("Client")).toBe("192.0.2.12");
});
test("a learned name is told as the reverse-DNS lookup it is, never as a recorded fact", async () => {
responses["/api/queries/13"] = detail(13, { request: { client: "192.0.2.13" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" });
renderDetail(13);
await screen.findByRole("heading", { name: "example.com" });
await relatedText("Reverse DNS currently resolves 192.0.2.13 to printer.lan.");
expect(factValue("Client")).toBe("192.0.2.13");
});
test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => {
renderDetail(404, "?mode=history&domain=gone");
await screen.findByRole("alert");
expect(screen.getByText(/no such query/)).toBeTruthy();
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
mode: "history",
domain: "gone",
});
});
@@ -0,0 +1,75 @@
import { useQuery } from "@tanstack/react-query";
import { Link, useParams, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { queryDetailQuery } from "@/lib/queries";
import type { QueryDetail } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import ProvenanceDetail from "./ProvenanceDetail";
import RelatedActions from "./RelatedActions";
import type { ActivitySearch } from "./search";
const styles = stylex.create({
back: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
loading: {
marginTop: "1rem",
color: colors.textMuted,
},
});
/**
* The way back to the investigation, not to a bare list. The originating
* Activity search rides in this route's own search, so the reader returns to
* the mode, the filters and the absolute window they left — a plain `/activity`
* would silently widen the range they had chosen.
*/
function BackLink({ origin }: { origin: ActivitySearch }) {
return (
<Link to="/activity" search={origin} {...stylex.props(styles.back, shared.focusRing)}>
Activity
</Link>
);
}
export default function ActivityDetailPage() {
const { id } = useParams({ from: "/shell/activity/queries/$id" });
const origin = useSearch({ from: "/shell/activity/queries/$id" });
const rowId = Number(id);
const { data, error, isPending, refetch } = useQuery(queryDetailQuery(rowId));
if (isPending) {
return (
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
Loading query
</p>
);
}
if (data === undefined) {
return (
<section>
<BackLink origin={origin} />
<InlineError error={error} onRetry={() => void refetch()} />
</section>
);
}
const detail: QueryDetail = data;
const { domain, client, time } = detail.request;
return (
<section>
<BackLink origin={origin} />
<ProvenanceDetail
provenance={detail}
persistedId={detail.id}
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
/>
</section>
);
}
@@ -0,0 +1,230 @@
/**
* The filter row over the Activity table.
*
* The applied state is the URL, never this form: what the reader sees is what
* the link they can paste to a housemate will show. So this holds a draft only,
* and the page remounts it whenever the applied search changes — a back button
* or a pasted URL has to move the form with it, and a form that seeded itself
* once would keep showing the previous investigation's filters.
*
* In live mode the row stays visible and disabled rather than disappearing: the
* filters are retained in the URL and apply again the moment history comes
* back, and hiding them would read as having lost them. The stream itself is
* unfiltered — the server sends every query — so a row that looked usable here
* would promise filtering that is not happening.
*/
import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
import type { ActivitySearch } from "./search";
const STATUS_OPTIONS = [
{ value: "any", label: "All" },
{ value: "blocked", label: "Blocked only" },
{ value: "allowed", label: "Allowed only" },
];
const styles = stylex.create({
/** One column on a phone, two from `sm`, five from `lg`. */
grid: {
marginTop: "1rem",
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
},
},
label: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
input: {
marginTop: "0.25rem",
width: "100%",
// A disabled native input keeps its value legible but reads as inert,
// matching what RAC does to the Select trigger beside it.
cursor: { default: null, ":disabled": "not-allowed" },
opacity: { default: null, ":disabled": 0.55 },
},
buttonRow: {
display: "flex",
alignItems: "flex-end",
gap: "0.5rem",
gridColumn: {
default: null,
"@media (min-width: 640px)": "span 2 / span 2",
"@media (min-width: 1024px)": "span 5 / span 5",
},
},
toolbarButton: {
fontWeight: 500,
},
error: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
});
/** The applied filters, with `mode` left to the page that owns the switch. */
export type AppliedFilters = Omit<ActivitySearch, "mode">;
/** Every filter off — what Clear applies, and the loader's empty-filter case. */
export const NO_FILTERS: AppliedFilters = {
domain: undefined,
client: undefined,
blocked: undefined,
since: undefined,
until: undefined,
};
function blockedOption(blocked: boolean | undefined): string {
if (blocked === undefined) return "any";
return blocked ? "blocked" : "allowed";
}
function optionBlocked(value: string): boolean | undefined {
if (value === "blocked") return true;
return value === "allowed" ? false : undefined;
}
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
return reason === "unparseable"
? `${label} is not a complete date and time.`
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
}
interface Props {
applied: AppliedFilters;
isDisabled: boolean;
onApply: (filters: AppliedFilters) => void;
onClear: () => void;
}
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
const [domain, setDomain] = useState(applied.domain ?? "");
const [client, setClient] = useState(applied.client ?? "");
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
const [error, setError] = useState<string | null>(null);
function submit(event: FormEvent) {
event.preventDefault();
const sinceValue = resolveDatetimeField(since);
if (!sinceValue.ok) {
setError(boundError("Since", sinceValue.reason));
return;
}
const untilValue = resolveDatetimeField(until);
if (!untilValue.ok) {
setError(boundError("Until", untilValue.reason));
return;
}
setError(null);
onApply({
domain: domain.trim() === "" ? undefined : domain.trim(),
client: client.trim() === "" ? undefined : client.trim(),
blocked: optionBlocked(blocked),
since: sinceValue.value,
until: untilValue.value,
});
}
function clear() {
setDomain("");
setClient("");
setBlocked("any");
setSince(datetimeField(undefined));
setUntil(datetimeField(undefined));
setError(null);
onClear();
}
return (
<>
<form onSubmit={submit} {...stylex.props(styles.grid)}>
<label {...stylex.props(styles.label)}>
Domain contains
<input
type="text"
value={domain}
disabled={isDisabled}
onChange={(event) => setDomain(event.target.value)}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</label>
<label {...stylex.props(styles.label)}>
Client (exact)
<input
type="text"
value={client}
disabled={isDisabled}
onChange={(event) => setClient(event.target.value)}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</label>
<Select
variant="compactField"
label="Result"
value={blocked}
isDisabled={isDisabled}
onChange={setBlocked}
options={STATUS_OPTIONS}
/>
<label {...stylex.props(styles.label)}>
Since
<input
type="datetime-local"
step={1}
value={since.text}
disabled={isDisabled}
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</label>
<label {...stylex.props(styles.label)}>
Until
<input
type="datetime-local"
step={1}
value={until.text}
disabled={isDisabled}
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</label>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={isDisabled}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
Apply filters
</button>
<button
type="button"
onClick={clear}
disabled={isDisabled}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
Clear
</button>
</div>
</form>
{error !== null && (
<p role="alert" {...stylex.props(styles.error)}>
{error}
</p>
)}
</>
);
}
@@ -0,0 +1,554 @@
/**
* Activity in history mode, through the real router: the URL is the applied
* state, so nothing here can be checked by rendering the page on its own.
*/
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/queries/provenanceFixture";
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 queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides });
}
const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 };
/** The blocked row every page fixture reuses. */
const BLOCKED = {
blocked: true,
policy_action: "block",
policy_reason: "blocklist_wildcard",
route_kind: "blocked",
upstream: "",
} as const satisfies Partial<QueryRow>;
const PAGES: Record<string, QueriesPage> = {
"/api/queries": {
queries: [
row(20, "first.example", { qtype: 65, cache_hit: true, route_kind: "cache" }),
row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }),
],
next_before: 19,
coverage: COMPLETE,
},
"/api/queries?before=19": {
queries: [row(5, "older.example")],
next_before: null,
coverage: COMPLETE,
},
"/api/queries?domain=ads": {
queries: [row(19, "ads.example", BLOCKED)],
next_before: null,
coverage: COMPLETE,
},
"/api/queries?domain=ads&blocked=true": {
queries: [row(19, "ads.example", BLOCKED)],
next_before: null,
coverage: COMPLETE,
},
"/api/queries?since=1700000000": {
queries: [row(20, "first.example")],
next_before: null,
coverage: COMPLETE,
},
};
let fetchMock: ReturnType<typeof vi.fn>;
function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
/** The shell's own requests, which every test serves the same way. */
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
return Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
}
function fromPages(url: string): Response {
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 json(payload);
}
beforeEach(() => {
stubFetch(fromPages);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderPage(path = "/activity") {
const queryClient = createQueryClient();
const history = createMemoryHistory({ initialEntries: [path] });
const router = createAppRouter(history, queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { queryClient, history };
}
/** Every `/api/queries` URL the run asked for, list pages only. */
function queryCalls(): string[] {
return fetchMock.mock.calls
.map((call) => String(call[0]))
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
}
test("renders the first page with the seven columns filled in", async () => {
renderPage();
await screen.findByText("first.example");
expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
"Time",
"Domain",
"Client",
"Type",
"Result",
"Route",
"Duration",
]);
const first = screen.getByText("first.example").closest("tr")!;
expect(within(first).getByText("HTTPS")).toBeTruthy();
expect(within(first).getByText("NOERROR")).toBeTruthy();
expect(within(first).getByText("Cache")).toBeTruthy();
expect(within(first).getByText("1.2 ms")).toBeTruthy();
const blocked = screen.getByText("ads.example").closest("tr")!;
// The Result cell says Blocked even though the client saw NOERROR, and the
// Route cell says how: this is the pair the old Status column could not show.
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
expect(within(blocked).getByText("—")).toBeTruthy();
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
});
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
stubFetch((url) => {
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,
coverage: COMPLETE,
} 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");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("older.example");
expect(screen.getByText("first.example")).toBeTruthy();
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
});
test("applying a filter puts it in the url, refetches, and resets the accumulated list", async () => {
const { history } = renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("older.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
await screen.findByText(/Showing 1 query /);
expect(history.location.search).toContain("domain=ads");
expect(screen.getByText("ads.example")).toBeTruthy();
expect(screen.queryByText("first.example")).toBeNull();
expect(screen.queryByText("older.example")).toBeNull();
});
test("a load-more that resolves after a filter change is discarded", async () => {
let releaseLoadMore: () => void = () => {};
stubFetch((url) => {
if (url === "/api/queries?before=19") {
return new Promise<Response>((resolve) => {
releaseLoadMore = () => resolve(json(PAGES["/api/queries?before=19"]));
});
}
return fromPages(url);
});
renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
await screen.findByText(/Showing 1 query /);
releaseLoadMore();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(screen.queryByText("older.example")).toBeNull();
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
let releaseFiltered: () => void = () => {};
const filteredPage: QueriesPage = {
queries: [row(19, "ads.example", BLOCKED)],
next_before: 7,
coverage: COMPLETE,
};
const filteredOlderPage: QueriesPage = {
queries: [row(3, "ads.older.example")],
next_before: null,
coverage: COMPLETE,
};
stubFetch((url) => {
if (url === "/api/queries?domain=ads") {
return new Promise<Response>((resolve) => {
releaseFiltered = () => resolve(json(filteredPage));
});
}
if (url === "/api/queries?domain=ads&before=7") return json(filteredOlderPage);
return fromPages(url);
});
renderPage();
await screen.findByText("first.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
const staleButton = await screen.findByRole("button", { name: "Load more" });
expect(staleButton).toHaveProperty("disabled", true);
fireEvent.click(staleButton);
expect(queryCalls()).not.toContain("/api/queries?domain=ads&before=19");
releaseFiltered();
await waitFor(() => {
expect(screen.queryByText("first.example")).toBeNull();
});
const freshButton = screen.getByRole("button", { name: "Load more" });
expect(freshButton).toHaveProperty("disabled", false);
fireEvent.click(freshButton);
await screen.findByText("ads.older.example");
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
});
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
// The newest-100 window moves up while the reader has a second page open.
// Refetching only the first page would drop n20 and n19 out of the middle
// of the table; the second page must be replayed from the fresh cursor.
const before: Record<string, QueriesPage> = {
"/api/queries": {
queries: [row(20, "n20.example"), row(19, "n19.example")],
next_before: 19,
coverage: COMPLETE,
},
"/api/queries?before=19": {
queries: [row(18, "n18.example"), row(17, "n17.example")],
next_before: null,
coverage: COMPLETE,
},
};
const after: Record<string, QueriesPage> = {
"/api/queries": {
queries: [row(22, "n22.example"), row(21, "n21.example")],
next_before: 21,
coverage: COMPLETE,
},
"/api/queries?before=21": {
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
next_before: null,
coverage: COMPLETE,
},
};
let live = before;
stubFetch((url) => {
const payload = live[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json(payload);
});
const { queryClient } = renderPage();
await screen.findByText("n20.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("n17.example");
live = after;
await act(async () => {
await queryClient.invalidateQueries({ queryKey: ["queries"] });
});
await screen.findByText("n22.example");
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
});
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
const assign = vi.fn();
vi.stubGlobal("location", { pathname: "/activity", search: "", assign });
stubFetch((url) => {
if (url === "/api/queries?before=19") {
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
return fromPages(url);
});
renderPage();
await screen.findByText("first.example");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await waitFor(() => {
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/activity")}`);
});
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByText(/Failed to load more/)).toBeNull();
});
test("each row links into its own detail page, carrying the investigation with it", async () => {
renderPage("/activity?mode=history&since=1700000000");
await screen.findByText("first.example");
const link = screen.getByRole("link", { name: "first.example" });
expect(link.getAttribute("href")).toContain("/activity/queries/20");
expect(link.getAttribute("href")).toContain("since=1700000000");
// An <a href> is in the tab order by default; nothing here may opt it out.
expect(link.getAttribute("tabindex")).toBeNull();
});
test("a pruned window tells the reader when history starts", async () => {
stubFetch((url) => {
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, "kept.example")],
next_before: null,
coverage: { complete: false, available_since: 1_700_000_000 },
} satisfies QueriesPage);
});
renderPage();
await screen.findByText("kept.example");
expect(screen.getByText(/Query history is available from/)).toBeTruthy();
});
test("a complete window shows no coverage notice", async () => {
renderPage();
await screen.findByText("first.example");
expect(screen.queryByText(/Query history is available from/)).toBeNull();
});
test("a ?domain= link seeds the filter form and fetches that domain on arrival", async () => {
renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
expect(screen.queryByText("first.example")).toBeNull();
});
test("history forwards exactly the six normalized filter fields and nothing else", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
renderPage(
"/activity?mode=history&domain=%20ads%20&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600&bogus=1&limit=9999",
);
await screen.findByText("No queries match the current filters.");
expect(queryCalls()).toEqual([
"/api/queries?domain=ads&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600",
]);
});
test("a rejected search parameter is dropped rather than guessed at", async () => {
renderPage("/activity?since=1.5&blocked=%22true%22&domain=%20%20");
await screen.findByText("first.example");
// Nothing survived validation, so the request is the unfiltered one.
expect(queryCalls()).toEqual(["/api/queries"]);
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
});
test("the form draft follows the url back and forward, seconds included", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
// A bound with non-zero seconds: the round trip has to keep them, and the
// untouched field has to carry the original number rather than re-parse.
const seeded = 1_700_000_017;
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
const domainInput = await screen.findByLabelText("Domain contains");
expect(domainInput).toHaveProperty("value", "first");
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
expect(sinceInput.value).toContain(":37");
fireEvent.change(domainInput, { target: { value: "second" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
await waitFor(() => expect(history.location.search).toContain("domain=second"));
// The untouched Since bound applied as the exact second it was seeded with.
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
act(() => history.back());
await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
});
act(() => history.forward());
await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
});
});
/** 2 a.m. on the EU spring-forward date: an hour that exists in some zones and not others. */
const DST_WALL_TIME = "2026-03-29T02:30:00";
/**
* Whether that wall time names an instant in the timezone the suite runs in.
* `new Date` slides a spring-forward gap silently forward, so an hour or minute
* that comes back different from the one written *is* the gap.
*/
function wallTimeExists(text: string): boolean {
const written = /T(\d{2}):(\d{2})/.exec(text)!;
const parsed = new Date(text);
return parsed.getHours() === Number(written[1]) && parsed.getMinutes() === Number(written[2]);
}
test("a wall-clock time the daylight-saving jump skips is refused, not silently moved", async () => {
// One expected outcome per timezone, decided here rather than accepted from
// the page: in a zone with the jump the bound must be refused outright, and
// in a zone without it the same text is an ordinary instant that applies.
const inGap = !wallTimeExists(DST_WALL_TIME);
const unix = Math.floor(new Date(DST_WALL_TIME).getTime() / 1000);
stubFetch((url) => {
if (url === `/api/queries?since=${unix}`) {
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
}
return fromPages(url);
});
const { history } = renderPage();
await screen.findByText("first.example");
const callsBefore = queryCalls().length;
const searchBefore = history.location.search;
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
if (inGap) {
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
// Refused means refused: no navigation, and no request for the hour the
// operator did not ask for.
expect(history.location.search).toBe(searchBefore);
expect(queryCalls()).toHaveLength(callsBefore);
return;
}
await waitFor(() => expect(history.location.search).toContain(`since=${unix}`));
expect(queryCalls()).toContain(`/api/queries?since=${unix}`);
expect(screen.queryByRole("alert")).toBeNull();
});
test("the policy simulation is reachable from the header, with no rows to click through", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url === "/api/groups") return json({ groups: [{ id: 1, name: "default", safe_search: false }] });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
const { history } = renderPage();
// An empty log is exactly the case a row-borne link cannot serve.
await screen.findByText("No queries logged yet.");
const link = screen.getByRole("link", { name: "Current policy simulation" });
expect(link.getAttribute("href")).toBe("/activity/test");
fireEvent.click(link);
await screen.findByRole("heading", { level: 1, name: "Current policy simulation" });
expect(history.location.pathname).toBe("/activity/test");
});
test("Clear empties the url as well as the form", async () => {
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
await screen.findByText("ads.example");
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(() => {
expect(history.location.search).not.toContain("domain");
});
expect(history.location.search).not.toContain("blocked");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
});
@@ -0,0 +1,152 @@
/**
* Activity: one surface over the queries nxdns answered, in two modes.
*
* History reads the persisted log and Live reads the stream, but they are the
* same seven columns over the same filters, and the reader moves between them
* without losing the question they were asking. The mode lives in the URL with
* the filters, so an investigation is one link including which half of it the
* recipient should be looking at.
*/
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
import HistoryActivity from "./HistoryActivity";
import LiveActivity from "./LiveActivity";
import type { ActivityMode } from "./search";
const MODES: ReadonlyArray<{ mode: ActivityMode; label: string }> = [
{ mode: "history", label: "History" },
{ mode: "live", label: "Live" },
];
const styles = stylex.create({
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
switch: {
display: "flex",
gap: "0.25rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "0.125rem",
},
modeButton: {
borderStyle: "none",
borderRadius: "0.1875rem",
paddingInline: "0.75rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
cursor: "pointer",
},
modeIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: { default: colors.textSecondary, ":hover": colors.text },
},
/** The selected mode reads as a filled chip, the same weight the nav uses. */
modeSelected: {
backgroundColor: colors.primary,
color: colors.primaryText,
},
/** The one way into the simulation from here, so it cannot sit behind a row. */
simulationLink: {
marginInlineStart: "auto",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
liveNote: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
export default function ActivityPage() {
const search = useSearch({ from: "/shell/activity" });
const navigate = useNavigate({ from: "/activity" });
const live = search.mode === "live";
// The functional form, not a replacement object: the filters are retained
// across a mode switch on purpose, and spelling out a new search here would
// drop every one of them on the way to Live and back.
function selectMode(mode: ActivityMode) {
if (mode === search.mode) return;
void navigate({ search: (prev) => ({ ...prev, mode }) });
}
function apply(filters: AppliedFilters) {
void navigate({ search: { mode: search.mode, ...filters } });
}
return (
<section>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Activity</h1>
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
{MODES.map((option) => {
const selected = option.mode === search.mode;
return (
<button
key={option.mode}
type="button"
aria-pressed={selected}
onClick={() => selectMode(option.mode)}
{...stylex.props(
styles.modeButton,
selected ? styles.modeSelected : styles.modeIdle,
shared.focusRing,
)}
>
{option.label}
</button>
);
})}
</div>
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
Current policy simulation
</Link>
</div>
{/*
* Remounted whenever the applied search changes, which is what makes
* the back button work: the draft is derived state, and the browser
* moving the URL under it has to move the form with it.
*/}
<ActivityFilters
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
applied={search}
isDisabled={live}
onApply={apply}
onClear={() => apply(NO_FILTERS)}
/>
{live ? (
<>
<p {...stylex.props(styles.liveNote)}>
The stream carries every query the server answers; these filters apply to history only.
</p>
<LiveActivity origin={search} />
</>
) : (
<HistoryActivity search={search} />
)}
</section>
);
}
@@ -0,0 +1,181 @@
/**
* Activity in history mode: the persisted queries the URL's filters select,
* paged by keyset cursor.
*
* The filters arrive already applied the URL is the applied state so this
* only reads them. Everything about how the reader got here lives one level up.
*/
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueryRow } from "@/lib/types";
import { useClientNames } from "@/features/clients/clientNames";
import { summarizeRow } from "@/features/queries/querySummary";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
import { queriesFilterOf, type ActivitySearch } from "./search";
const styles = stylex.create({
empty: {
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
table: {
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
row: {
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
footer: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
note: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
refetching: {
marginTop: "0.75rem",
},
moreButton: {
fontWeight: 500,
},
moreError: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
});
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export default function HistoryActivity({ search }: { search: ActivitySearch }) {
const filter = queriesFilterOf(search);
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
const clientNames = useClientNames();
const pages = base.data?.pages ?? [];
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
const coverage = pages[0]?.coverage;
const filterActive = Object.keys(filter).length > 0;
// `base.hasNextPage` reads the query state, which is empty while placeholder
// data stands in for a filter change; derive the cursor from what is on
// screen so the button keeps its place instead of flashing "end of log".
const lastPage = pages[pages.length - 1];
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
// A 401 is already redirecting via the cache-level handleUnauthorized.
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
function loadMore() {
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
void base.fetchNextPage();
}
// The loader starts this fetch but does not wait for it, so both the first
// paint and a failed first page are this component's to render.
if (base.status === "error" && base.data === undefined) {
return <InlineError error={base.error} onRetry={() => void base.refetch()} />;
}
if (base.data === undefined) {
return (
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
Loading activity
</p>
);
}
return (
<>
{base.isFetching && (
<p {...stylex.props(styles.note, styles.refetching)} role="status">
Loading
</p>
)}
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
{rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p>
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
{rows.map((row) => (
<tr key={row.id} {...stylex.props(styles.row)}>
<ActivityCells
row={summarizeRow(row)}
clientNames={clientNames}
renderDomain={(id, children) =>
id === null ? (
children
) : (
<Link
to="/activity/queries/$id"
params={{ id: String(id) }}
search={search}
{...stylex.props(activityDomainLink, shared.focusRing)}
>
{children}
</Link>
)
}
/>
</tr>
))}
</tbody>
</table>
</div>
<div {...stylex.props(styles.footer)}>
<p {...stylex.props(styles.note)}>
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"}
</p>
{hasMore && (
<button
type="button"
onClick={loadMore}
disabled={base.isFetchingNextPage || base.isPlaceholderData}
{...stylex.props(shared.button, styles.moreButton, shared.focusRing)}
>
{base.isFetchingNextPage ? "Loading…" : "Load more"}
</button>
)}
</div>
{moreError !== null && (
<p role="alert" {...stylex.props(styles.moreError)}>
Failed to load more: {moreError}
</p>
)}
</>
)}
</>
);
}
@@ -0,0 +1,399 @@
/**
* Activity in live mode, through the real router.
*
* The EventSource is a global here rather than an injected factory: whether the
* connection exists at all is the thing under test, and that is decided by
* which subtree the URL mounts, not by a prop a caller could pass.
*/
import { act, fireEvent, render, screen, 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 { Client } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { FakeEventSource } from "./fakeEventSource";
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", "", ""),
];
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
let sources: FakeEventSource[];
let fetchMock: ReturnType<typeof vi.fn>;
function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
if (url === "/api/clients") return Promise.resolve(json({ clients: CLIENTS }));
return Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
}
beforeEach(() => {
sources = [];
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
const source = new FakeEventSource(url);
sources.push(source);
return source as unknown as EventSource;
}
},
);
stubFetch();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function frame(ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): { data: string } {
return {
data: JSON.stringify(
provenance({
...sections,
request: { time: ts, domain, ...sections.request },
route: { kind: "cache", upstream: "", ...sections.route },
}),
),
};
}
function renderPage(path = "/activity?mode=live") {
const queryClient = createQueryClient();
const history = createMemoryHistory({ initialEntries: [path] });
const router = createAppRouter(history, queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { history };
}
async function openLive(path?: string) {
const rendered = renderPage(path);
await screen.findByRole("button", { name: "Freeze" });
act(() => sources[0]!.emit("open"));
return rendered;
}
function queryCalls(): string[] {
return fetchMock.mock.calls
.map((call) => String(call[0]))
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
}
test("streams rows, flags blocked ones, and freezes the display", async () => {
await openLive();
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", {
request: { qtype: 28 },
policy: { action: "block", reason: "blocklist_wildcard" },
route: { kind: "blocked" },
}),
);
});
expect(screen.getByText("ok.example")).toBeTruthy();
expect(screen.getByText("AAAA")).toBeTruthy();
const blockedRow = screen.getByText("ads.example").closest("tr")!;
expect(within(blockedRow).getAllByText("Blocked")).toHaveLength(2);
// 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 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 () => {
await openLive();
act(() => {
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
sources[0]!.emit("query", frame(1001, "learned.example", { request: { client: "192.0.2.11" } }));
sources[0]!.emit("query", frame(1002, "nameless.example", { request: { client: "192.0.2.12" } }));
sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } }));
});
const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10");
expect(screen.queryByText("pi.lan")).toBeNull();
const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
});
test("rows stream in as bare IPs while the client list is still loading", async () => {
let releaseClients: () => void = () => {};
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
return new Promise<Response>((resolve) => {
if (url !== "/api/clients") {
resolve(json({}));
return;
}
releaseClients = () => resolve(json({ clients: CLIENTS }));
});
});
vi.stubGlobal("fetch", fetchMock);
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "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", async () => {
renderPage();
await screen.findByRole("button", { name: "Freeze" });
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();
});
test("a recovered row links to its stored detail; a streamed one opens in place instead", async () => {
stubFetch((url) => {
if (url.startsWith("/api/queries?")) {
return json({
queries: [queryRow(88, { ts: 1001, domain: "recovered.example" })],
next_before: null,
coverage: { complete: true, available_since: 0 },
});
}
return json({});
});
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
act(() => sources[0]!.emit("error"));
act(() => sources[0]!.emit("open"));
const recovered = await screen.findByRole("link", { name: "recovered.example" });
expect(recovered.getAttribute("href")).toContain("/activity/queries/88");
// The streamed frame precedes its own insert, so it has no row to link to —
// but it does carry its own provenance, so it still has a detail.
expect(screen.queryByRole("link", { name: "streamed.example" })).toBeNull();
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
});
test("a streamed row opens its own provenance, from the keyboard as well as the pointer", async () => {
await openLive();
act(() =>
sources[0]!.emit(
"query",
frame(1000, "streamed.example", {
policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" },
route: { kind: "blocked", upstream: "" },
}),
),
);
const trigger = screen.getByRole("button", { name: "streamed.example" });
// A real <button> is in the tab order and activates on Enter and Space; the
// only way to lose that is to opt out of it, which nothing here may do.
expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("tabindex")).toBeNull();
act(() => trigger.focus());
expect(document.activeElement).toBe(trigger);
fireEvent.click(trigger);
const heading = screen.getByRole("heading", { level: 1, name: "streamed.example" });
expect(heading).toBeTruthy();
const panel = heading.closest("div")!.parentElement!;
expect(within(panel).getByText("Blocked locally")).toBeTruthy();
expect(panel.textContent).toContain("the query log may not have written it yet");
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
});
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
const trigger = screen.getByRole("button", { name: "streamed.example" });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-controls")).toBeNull();
// A native button activates on Enter and Space; jsdom does not synthesize
// the click those keys fire, so the click is the activation.
act(() => trigger.focus());
fireEvent.click(trigger);
const panel = screen.getByRole("group", { name: "Streamed query" });
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
// The panel is inserted above the table, behind the trigger in tab order, so
// the only thing that keeps a forward tab inside it is focus moving in.
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(document.activeElement).toBe(panel);
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-controls")).toBeNull();
});
test("opening a second row moves the expanded state and the focus with it", async () => {
await openLive();
act(() => {
sources[0]!.emit("query", frame(1000, "first.example"));
sources[0]!.emit("query", frame(1001, "second.example"));
});
const first = screen.getByRole("button", { name: "first.example" });
const second = screen.getByRole("button", { name: "second.example" });
fireEvent.click(first);
fireEvent.click(second);
const panel = screen.getByRole("group", { name: "Streamed query" });
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
expect(document.activeElement).toBe(panel);
expect(first.getAttribute("aria-expanded")).toBe("false");
expect(second.getAttribute("aria-expanded")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(document.activeElement).toBe(second);
});
test("an open streamed detail survives the row being evicted from the ring buffer", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
// 500 more queries: the ring keeps the newest 500, so the selected row is
// gone from the table. The detail is a snapshot, not a lookup into the ring.
act(() => {
for (let index = 0; index < 500; index += 1) {
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
}
});
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
});
test("an open streamed detail survives Freeze and Resume", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
});
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
expect(domain.value).toBe("ads");
expect(domain.disabled).toBe(true);
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
const form = domain.closest("form")!;
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
expect(controls.length).toBeGreaterThan(0);
for (const control of controls) {
// A disabled form control is skipped by the browser's tab order, and RAC
// pins its own trigger out of it as well. Nothing in the row may
// reintroduce itself with a reachable tabindex.
expect(control.hasAttribute("disabled")).toBe(true);
const tabindex = control.getAttribute("tabindex");
expect(tabindex === null || tabindex === "-1").toBe(true);
}
});
test("live mode asks for no query pages, whatever filters the url retained", async () => {
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true&since=1700000000&bogus=1");
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
expect(queryCalls()).toEqual([]);
});
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
await openLive("/activity?mode=live&domain=ads");
fireEvent.click(screen.getByRole("button", { name: "History" }));
await screen.findByRole("button", { name: "Apply filters" });
expect(sources).toHaveLength(1);
expect(sources[0]!.closed).toBe(true);
// The filters came along, which is the point of switching rather than
// navigating: the reader keeps the question they were asking.
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Live" }));
await screen.findByRole("button", { name: "Freeze" });
expect(sources).toHaveLength(2);
expect(sources[1]!.closed).toBe(false);
});
@@ -0,0 +1,421 @@
/**
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
* in-place detail a streamed row opens.
*
* This subtree is mounted only while the URL says `mode=live`, which is what
* closes the EventSource on the way back to history: the connection is a
* server-side resource capped per address, so a page that kept it open while
* showing something else would spend a viewer slot on nothing.
*
* Freeze and Follow are display state and stay out of the URL. They describe
* what the screen is doing right now, not what it is showing, so a shared link
* would carry a frozen moment the recipient never saw fill.
*/
import { useEffect, useRef, useState } from "react";
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames";
import { summarizeEvent } from "@/features/queries/querySummary";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
import ProvenanceDetail from "./ProvenanceDetail";
import RelatedActions from "./RelatedActions";
import { RING_CAPACITY, summaryOf, type StreamedRow } from "./ringBuffer";
import type { ActivitySearch } from "./search";
import { useLiveQueries, type StreamStatus } from "./useLiveQueries";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
toolbar: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
toolbarButton: {
fontWeight: 500,
},
pill: {
borderRadius: "9999px",
paddingInline: "0.625rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/** Four stream states need four tints; only two of them map onto a token role. */
pillConnecting: {
backgroundColor: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
color: { default: "oklch(37% 0.013 285.805)", [DARK]: "oklch(87.1% 0.006 286.286)" },
},
pillOpen: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(39.3% 0.095 152.535)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(92.5% 0.084 155.995)" },
},
pillRetrying: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(92.4% 0.12 95.746)" },
},
pillCapped: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
},
note: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** Informational, neither a warning nor a failure, so the blue ramp stands alone. */
resumed: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
backgroundColor: { default: "oklch(97% 0.014 254.604)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(88.2% 0.059 254.128)" },
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
dismiss: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
fontWeight: 500,
textDecorationLine: "underline",
},
failureNote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
cappedBox: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
padding: "1rem",
},
cappedHeading: {
fontWeight: 600,
color: colors.dangerText,
},
cappedDetail: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
empty: {
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
table: {
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
row: {
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
rowBlocked: {
backgroundColor: {
default: "oklch(97.1% 0.013 17.38)",
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
},
},
/** A streamed row opens its detail here rather than at a route, so it is a button that looks like the link beside it. */
domainButton: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
font: "inherit",
textAlign: "left",
cursor: "pointer",
textDecorationLine: "underline",
textDecorationStyle: "dotted",
},
detailPanel: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surface,
padding: "1rem",
},
detailBar: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: "0.75rem",
},
detailLabel: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: colors.textMuted,
},
footnote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
const DETAIL_PANEL_ID = "live-query-detail";
const DETAIL_LABEL_ID = "live-query-detail-label";
const PILL_LABELS: Record<StreamStatus, string> = {
connecting: "Connecting…",
open: "Live",
retrying: "Reconnecting…",
capped: "Disconnected",
};
function pillStyle(status: StreamStatus) {
if (status === "open") return styles.pillOpen;
if (status === "retrying") return styles.pillRetrying;
if (status === "capped") return styles.pillCapped;
return styles.pillConnecting;
}
function StatusPill({ status }: { status: StreamStatus }) {
const label = PILL_LABELS[status];
return (
<span role="status" aria-label={label} {...stylex.props(styles.pill, pillStyle(status))}>
{label}
</span>
);
}
/**
* The open detail, held as the selected row itself rather than as a key into
* the buffer.
*
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
* key would go stale under the reader while they were still reading it, and the
* panel would blank out for no reason they could see. The snapshot is the whole
* fact a streamed frame carries its own provenance so it survives eviction,
* a merge and a Freeze/Resume, and closes only when the reader closes it or
* leaves live mode.
*/
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
const summary = summarizeEvent(row.event);
const panel = useRef<HTMLDivElement>(null);
// The panel opens above the table, behind the trigger in tab order, so a
// forward tab from the row would walk past it. Focus moves in on open —
// keyed on the row, so choosing a second row moves it again — and the
// closer puts it back on the trigger.
useEffect(() => {
panel.current?.focus();
}, [row.key]);
return (
<div
ref={panel}
id={DETAIL_PANEL_ID}
tabIndex={-1}
role="group"
aria-labelledby={DETAIL_LABEL_ID}
{...stylex.props(styles.detailPanel)}
>
<div {...stylex.props(styles.detailBar)}>
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
Streamed query
</span>
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Close
</button>
</div>
<ProvenanceDetail
provenance={row.event}
persistedId={null}
relatedActions={
<RelatedActions
domain={summary.domain}
client={summary.client_ip}
ts={summary.ts}
origin={origin}
/>
}
/>
</div>
);
}
export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
const live = useLiveQueries();
const clientNames = useClientNames();
const [selected, setSelected] = useState<StreamedRow | null>(null);
const trigger = useRef<HTMLButtonElement | null>(null);
function open(row: StreamedRow, from: HTMLButtonElement) {
trigger.current = from;
setSelected(row);
}
// The row that opened the panel takes focus back, unless the ring has
// already evicted it: a detached button cannot be focused, and the browser
// falls back to the document, which is the best available answer.
function close() {
setSelected(null);
trigger.current?.focus();
trigger.current = null;
}
return (
<>
<div {...stylex.props(styles.toolbar)}>
<StatusPill status={live.status} />
<button
type="button"
onClick={live.toggleFreeze}
aria-pressed={live.frozen}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
{live.frozen ? "Resume" : "Freeze"}
</button>
</div>
{live.frozen && (
<p {...stylex.props(styles.note)} role="status">
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
kept).
</p>
)}
{live.missed !== null && (
<div role="status" {...stylex.props(styles.resumed)}>
<span>
Stream resumed {" "}
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
</span>
<button
type="button"
onClick={live.dismissMissed}
{...stylex.props(styles.dismiss, shared.focusRing)}
>
Dismiss
</button>
</div>
)}
{live.resyncFailed && (
<p role="alert" {...stylex.props(styles.failureNote)}>
Stream resumed, but re-syncing the gap failed some queries may be missing here.
</p>
)}
{live.status === "capped" && (
<div role="alert" {...stylex.props(styles.cappedBox)}>
<h2 {...stylex.props(styles.cappedHeading)}>Live stream unavailable</h2>
<p {...stylex.props(styles.cappedDetail)}>
The connection failed repeatedly possibly too many live viewers (the server caps streams per
address), or the server is unreachable.
</p>
<button type="button" onClick={live.retry} {...stylex.props(shared.retryButton, shared.focusRing)}>
Retry
</button>
</div>
)}
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
{live.rows.map((row) => {
const summary = summaryOf(row);
return (
<tr
key={row.key}
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
>
<ActivityCells
row={summary}
clientNames={clientNames}
renderDomain={(_id, children) =>
row.kind === "streamed" ? (
<button
type="button"
aria-expanded={selected?.key === row.key}
aria-controls={
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
}
onClick={(event) => open(row, event.currentTarget)}
{...stylex.props(
styles.domainButton,
activityDomainLink,
shared.focusRing,
)}
>
{children}
</button>
) : (
<Link
to="/activity/queries/$id"
params={{ id: String(row.row.id) }}
search={origin}
{...stylex.props(activityDomainLink, shared.focusRing)}
>
{children}
</Link>
)
}
/>
</tr>
);
})}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{RING_CAPACITY} kept).
</p>
</>
)}
</>
);
}
@@ -0,0 +1,178 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import type { LookupResult } from "@/lib/types";
const BLOCKED: LookupResult = {
domain: "ads.example",
group_id: 1,
local_records: false,
forward_zone: null,
blocked: true,
reason: "blocklist_domain",
matched: "ads.example",
source_url: "https://lists.test/a",
safe_search_rewrite: null,
};
let fetchMock: ReturnType<typeof createFetchMock>;
/** What `/api/lookup` answers, so a test can make it fail without rebuilding the mock. */
let lookup: (url: string) => Response;
function json(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json", ...headers },
});
}
function createFetchMock() {
return vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/groups") {
return json({
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
});
}
if (url.startsWith("/api/lookup")) return lookup(url);
return json({ error: "not stubbed" }, 404);
});
}
beforeEach(() => {
lookup = (url) =>
url === "/api/lookup?domain=ads.example&group_id=1" ? json(BLOCKED) : json({ error: "not stubbed" }, 404);
fetchMock = createFetchMock();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/**
* `retry: false` for the failure tests: the shared client retries a 5xx twice
* and a 429 after its Retry-After, so the surfaced error is what the page does
* once the client has given up, not something a test should sit out in real
* time.
*/
function renderPage(path = "/activity/test", { 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>,
);
}
function lookupCalls(): string[] {
return fetchMock.mock.calls.map(([input]) => String(input)).filter((url) => url.startsWith("/api/lookup"));
}
test("fetches nothing until submit, then renders the blocked verdict", async () => {
renderPage();
await screen.findByRole("heading", { name: "Current policy simulation" });
await screen.findByLabelText("Group");
expect(lookupCalls()).toEqual([]);
fireEvent.change(screen.getByLabelText("Domain"), { target: { value: "ads.example" } });
expect(lookupCalls()).toEqual([]);
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
await screen.findByRole("heading", { name: "Blocked" });
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
expect(screen.getByText("blocklist_domain")).toBeTruthy();
const link = screen.getByRole("link", { name: "https://lists.test/a" }) as HTMLAnchorElement;
expect(link.href).toBe("https://lists.test/a");
expect(screen.getByText("Queries for this name get a blocked response.")).toBeTruthy();
});
test("a ?domain= link asks the question on arrival instead of leaving a filled-in form", async () => {
renderPage("/activity/test?domain=ads.example");
// No submit here: the link is the question, so the verdict is what arrives.
await screen.findByRole("heading", { name: "Blocked" });
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
expect(screen.getByLabelText("Domain")).toHaveProperty("value", "ads.example");
});
test("no filter snapshot reads as a server that is starting, not as a verdict", async () => {
lookup = () => json({ error: "no snapshot" }, 503);
renderPage("/activity/test", { retry: false });
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("No filter snapshot is loaded yet");
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
// Nothing may read as an answer while the lookup has none.
expect(screen.queryByRole("heading", { name: "Blocked" })).toBeNull();
expect(screen.queryByText("Simulating…")).toBeNull();
});
test("a rate limit says how long to wait, from the server's own Retry-After", async () => {
lookup = () => json({ error: "rate limited" }, 429, { "retry-after": "12" });
renderPage("/activity/test", { retry: false });
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 12s.");
});
test("resubmitting the same domain and group refetches rather than showing a stale verdict", async () => {
renderPage();
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
await screen.findByRole("heading", { name: "Blocked" });
expect(lookupCalls()).toHaveLength(1);
// The policy can change between two identical questions, so the second one
// has to reach the server even though the query key has not moved.
lookup = () => json({ ...BLOCKED, blocked: false, reason: "no_match", matched: "", source_url: null });
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
await screen.findByRole("heading", { name: "Allowed" });
expect(lookupCalls()).toEqual([
"/api/lookup?domain=ads.example&group_id=1",
"/api/lookup?domain=ads.example&group_id=1",
]);
});
test("defaults the group select to the default group (id 1)", async () => {
renderPage();
// A RAC Select names its trigger with the current value and then the label, so
// the selected group's name is the only thing the trigger shows.
const trigger = await screen.findByRole("button", { name: /Group$/ });
expect(trigger.textContent).toContain("default");
});
test("the framing is forward-tense, so it cannot be read as an account of a past query", async () => {
renderPage();
await screen.findByRole("heading", { name: "Current policy simulation" });
const intro = screen.getByRole("heading", { name: "Current policy simulation" }).nextElementSibling;
expect(intro?.textContent).toContain("would");
expect(intro?.textContent).toContain("right now");
// Nothing on the page may claim to explain a query that already happened.
expect(document.body.textContent).not.toContain("Look up");
});
@@ -0,0 +1,331 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
interface Submitted {
domain: string;
groupId: number;
}
interface Verdict {
label: string;
tone: "local" | "blocked" | "forwarded" | "allowed";
description: string;
}
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
color: colors.textMuted,
},
form: {
marginTop: "1.5rem",
display: "flex",
maxWidth: "42rem",
flexWrap: "wrap",
alignItems: "flex-end",
gap: "0.75rem",
},
domainField: {
minWidth: "14rem",
flexGrow: 1,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
note: {
marginTop: "1.5rem",
color: colors.textMuted,
},
error: {
marginTop: "1.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
card: {
marginTop: "1.5rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
banner: {
borderStartStartRadius: "0.25rem",
borderStartEndRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
/** Four verdicts need four tints; only "blocked" maps onto a token role. */
local: {
backgroundColor: { default: "oklch(93.2% 0.032 255.585)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(80.9% 0.105 251.813)" },
},
blocked: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
forwarded: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
allowed: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(26.6% 0.065 152.934)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(87.1% 0.15 154.449)" },
},
verdictLabel: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
verdictDescription: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
details: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
detailRow: {
display: "flex",
gap: "1rem",
paddingBlock: "0.5rem",
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
detailTerm: {
width: "10rem",
flexShrink: 0,
color: colors.textMuted,
},
detailValue: {
minWidth: 0,
overflowWrap: "break-word",
},
sourceLink: {
color: colors.primaryOnSurface,
textDecorationLine: "underline",
},
});
function toneStyle(tone: Verdict["tone"]) {
if (tone === "local") return styles.local;
if (tone === "blocked") return styles.blocked;
return tone === "forwarded" ? styles.forwarded : styles.allowed;
}
/**
* Header priority follows the pipeline order the lookup handler documents
* (PLAN §6): local records answer first, then the block decision, then
* forward zones, then plain forwarding to the upstream pool.
*/
export function verdictOf(result: LookupResult): Verdict {
if (result.local_records) {
return {
label: "Local answer",
tone: "local",
description: "A local record answers this name directly.",
};
}
if (result.blocked) {
return {
label: "Blocked",
tone: "blocked",
description: "Queries for this name get a blocked response.",
};
}
if (result.forward_zone !== null) {
return {
label: "Forwarded",
tone: "forwarded",
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
};
}
return {
label: "Allowed",
tone: "allowed",
description: "Queries resolve through the upstream pool.",
};
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) {
if (error.status === 503) {
return "No filter snapshot is loaded yet — the server is starting or degraded. Try again shortly.";
}
if (error.status === 429) {
return error.retryAfter !== undefined
? `Rate limited. Try again in ${error.retryAfter}s.`
: "Rate limited. Try again shortly.";
}
return error.message;
}
return "Could not reach the server.";
}
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div {...stylex.props(styles.detailRow)}>
<dt {...stylex.props(styles.detailTerm)}>{label}</dt>
<dd {...stylex.props(styles.detailValue)}>{children}</dd>
</div>
);
}
function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[] }) {
const verdict = verdictOf(result);
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
return (
<div {...stylex.props(styles.card)}>
<div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
<h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
<p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
</div>
<dl {...stylex.props(styles.details)}>
<DetailRow label="Domain">
<span {...stylex.props(shared.mono)}>{result.domain}</span>
</DetailRow>
<DetailRow label="Group">{groupName}</DetailRow>
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
<DetailRow label="Forward zone">
{result.forward_zone !== null ? (
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
) : (
"—"
)}
</DetailRow>
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
<DetailRow label="Reason">
<span {...stylex.props(shared.mono)}>{result.reason}</span>
</DetailRow>
<DetailRow label="Matched pattern">
{result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
</DetailRow>
<DetailRow label="Blocklist source">
{result.source_url !== null ? (
<a
href={result.source_url}
target="_blank"
rel="noreferrer"
{...stylex.props(styles.sourceLink, shared.focusRing)}
>
{result.source_url}
</a>
) : (
"—"
)}
</DetailRow>
<DetailRow label="Safe search rewrite">
{result.safe_search_rewrite !== null ? (
<span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
) : (
"—"
)}
</DetailRow>
</dl>
</div>
);
}
export default function PolicyTestPage() {
const groups = useSuspenseQuery(groupsQuery()).data;
const preselectedGroupId = defaultGroupId(groups);
// A `?domain=` link (from a query's detail page) arrives already asking the
// question, so it runs the simulation rather than leaving a filled-in form.
const search = useSearch({ from: "/shell/activity/test" });
const [domain, setDomain] = useState(search.domain ?? "");
const [groupId, setGroupId] = useState(preselectedGroupId);
const [submitted, setSubmitted] = useState<Submitted | null>(
search.domain === undefined ? null : { domain: search.domain, groupId: preselectedGroupId },
);
const lookup = useQuery({
...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
enabled: submitted !== null,
});
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = domain.trim();
if (trimmed === "") return;
if (submitted !== null && submitted.domain === trimmed && submitted.groupId === groupId) {
void lookup.refetch();
return;
}
setSubmitted({ domain: trimmed, groupId });
}
return (
<section>
<h1 {...stylex.props(styles.heading)}>Current policy simulation</h1>
<p {...stylex.props(styles.intro)}>
What the pipeline <em>would</em> do with a domain right now: local records, forward zones, block
decision, safe search. This reads the configuration in force at this moment, so it explains nothing
about a query already answered a detail page does that.
</p>
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div {...stylex.props(styles.domainField)}>
<label htmlFor="policy-test-domain" {...stylex.props(styles.fieldLabel)}>
Domain
</label>
<input
id="policy-test-domain"
required
value={domain}
onChange={(event) => setDomain(event.target.value)}
placeholder="ads.example.com"
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
</div>
<button
type="submit"
disabled={lookup.isFetching}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Simulate
</button>
</form>
{lookup.isFetching && <p {...stylex.props(styles.note)}>Simulating</p>}
{!lookup.isFetching && lookup.isError && (
<p role="alert" {...stylex.props(styles.error)}>
{errorMessage(lookup.error)}
</p>
)}
{!lookup.isFetching && lookup.data !== undefined && !lookup.isError && (
<VerdictCard result={lookup.data} groups={groups} />
)}
</section>
);
}
@@ -0,0 +1,300 @@
/**
* One query, explained in the order it met the pipeline.
*
* This is the body of the detail surface, with no route in it, because two
* surfaces show it: the persisted detail page, which fetched the row by id, and
* a live row, whose provenance arrived in the stream frame and which SQLite may
* not have written yet. The related actions are links into routes, so the
* caller passes them in already built.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatMicros, formatTime } from "@/lib/format";
import type { PolicyReason, Provenance } from "@/lib/types";
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
import {
policyActionLabel,
policyReasonLabel,
qclassName,
rcodeName,
routeKindLabel,
} from "@/features/queries/provenanceCopy";
import { qtypeName } from "@/features/queries/qtype";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
heading: {
marginTop: "0.5rem",
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
wordBreak: "break-all",
},
subtitle: {
marginTop: "0.25rem",
color: colors.textSecondary,
},
/** The recorded facts, fenced off from the live links below them. */
record: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: "1rem",
},
recordNote: {
fontSize: "0.8125rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
section: {
marginTop: "1rem",
borderTopWidth: { default: 1, ":first-of-type": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
paddingTop: { default: "1rem", ":first-of-type": 0 },
},
sectionHeading: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: colors.textMuted,
},
facts: {
marginTop: "0.5rem",
marginBottom: 0,
display: "grid",
gap: "0.375rem 1rem",
gridTemplateColumns: {
default: "auto",
"@media (min-width: 640px)": "max-content 1fr",
},
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
term: {
color: colors.textMuted,
},
value: {
margin: 0,
wordBreak: "break-all",
},
muted: {
color: colors.textMuted,
},
related: {
marginTop: "1.5rem",
maxWidth: "48rem",
},
relatedHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
relatedNote: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
relatedList: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
gap: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
link: {
color: colors.primaryOnSurface,
},
});
/** The look of one related action, so every caller's links match. */
export const provenanceRelatedLink = styles.link;
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.sectionHeading)}>{title}</h2>
<dl {...stylex.props(styles.facts)}>{children}</dl>
</div>
);
}
function Fact({ label, children }: { label: string; children: ReactNode }) {
return (
<>
<dt {...stylex.props(styles.term)}>{label}</dt>
<dd {...stylex.props(styles.value)}>{children}</dd>
</>
);
}
/**
* A recorded name, in the face the rest of the interface gives to names. It
* wraps the value rather than the row, so the prose that stands in for a
* missing one is not set in the same typewriter face.
*/
function Mono({ children }: { children: string }) {
return <span {...stylex.props(shared.mono)}>{children}</span>;
}
/** An empty text field means the server recorded nothing there, never an empty value. */
function Absent({ children }: { children: string }) {
return <span {...stylex.props(styles.muted)}>{children}</span>;
}
/**
* What an empty `matched` means. `no_match` is the only reason the matcher
* itself records with nothing to show; every other empty one names a pipeline
* step that answered before filtering a local record, a forward zone, a pause
* (see `PolicyReason` in src/storage/provenance.zig) where "nothing matched"
* would claim an evaluation that never happened.
*/
function unmatchedLabel(reason: PolicyReason): string {
return reason === "no_match" ? "Nothing matched" : "The matcher never ran";
}
interface Props {
provenance: Provenance;
/**
* The log row this explains, or null for a frame read straight off the
* stream. Null is the same fact `QuerySummary.id` carries: the event
* precedes its own insert, so there is no row to name and none is invented.
*/
persistedId: number | null;
/** The related-action links, built by whichever surface owns the routes. */
relatedActions: ReactNode;
}
export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) {
const { request, group, policy, rewrites, route, response } = provenance;
const clientNames = useClientNames();
const currentClient = clientLabel(request.client, clientNames);
return (
<>
<h1 {...stylex.props(styles.heading, shared.mono)}>{request.domain}</h1>
<p {...stylex.props(styles.subtitle)}>
{formatTime(request.time)} {policyActionLabel(policy.action)}
{/* The verdict alone reads as a success; a non-NOERROR answer says otherwise. */}
{response.rcode !== 0 && `${rcodeName(response.rcode)}`}
</p>
<div {...stylex.props(styles.record)}>
<p {...stylex.props(styles.recordNote)}>
What was recorded when this query was answered. Group and blocklist names are the ones in force at
that moment; they may have been renamed or deleted since.
{persistedId === null &&
" This is the event as it was streamed; the query log may not have written it yet."}
</p>
<Section title="Request">
<Fact label="Time">{formatTime(request.time)}</Fact>
<Fact label="Domain">
<Mono>{request.domain}</Mono>
</Fact>
<Fact label="Client">
<Mono>{request.client}</Mono>
</Fact>
<Fact label="Type">{qtypeName(request.qtype)}</Fact>
<Fact label="Class">{qclassName(request.qclass)}</Fact>
</Section>
<Section title="Group">
<Fact label="Name">{group.name === "" ? <Absent>No group recorded</Absent> : group.name}</Fact>
<Fact label="Id">{group.id === null ? <Absent></Absent> : group.id}</Fact>
</Section>
<Section title="Policy">
<Fact label="Decision">{policyActionLabel(policy.action)}</Fact>
<Fact label="Reason">{policyReasonLabel(policy.reason)}</Fact>
<Fact label="Matched">
{policy.matched === "" ? (
<Absent>{unmatchedLabel(policy.reason)}</Absent>
) : (
<Mono>{policy.matched}</Mono>
)}
</Fact>
<Fact label="Blocklist">
{policy.source_name === "" ? (
<Absent>Not a blocklist decision</Absent>
) : policy.source_id === null ? (
policy.source_name
) : (
`${policy.source_name} (#${policy.source_id})`
)}
</Fact>
</Section>
<Section title="Rewrites">
<Fact label="CNAME target">
{rewrites.cname_target === "" ? (
<Absent>The queried name was decided directly</Absent>
) : (
<Mono>{rewrites.cname_target}</Mono>
)}
</Fact>
<Fact label="Safe search">
{rewrites.safe_search_target === "" ? (
<Absent>No rewrite</Absent>
) : (
<Mono>{rewrites.safe_search_target}</Mono>
)}
</Fact>
</Section>
<Section title="Route">
<Fact label="Answered by">{routeKindLabel(route.kind)}</Fact>
<Fact label="Forward zone">
{route.forward_zone === "" ? <Absent></Absent> : <Mono>{route.forward_zone}</Mono>}
</Fact>
<Fact label="Upstream">
{route.upstream === "" ? <Absent>No upstream exchange</Absent> : <Mono>{route.upstream}</Mono>}
</Fact>
</Section>
<Section title="Response">
<Fact label="Result">{rcodeName(response.rcode)}</Fact>
<Fact label="Took">
{response.duration_us === null ? (
<Absent>Not measured</Absent>
) : (
formatMicros(response.duration_us)
)}
</Fact>
</Section>
</div>
<div {...stylex.props(styles.related)}>
<h2 {...stylex.props(styles.relatedHeading)}>Related</h2>
<p {...stylex.props(styles.relatedNote)}>
These read the current configuration, which may no longer be the one that decided this query.
</p>
{currentClient !== null && (
<p {...stylex.props(styles.relatedNote)}>
{currentClient.learned ? (
<>
Reverse DNS currently resolves <Mono>{request.client}</Mono> to{" "}
<Mono>{currentClient.text}</Mono>.
</>
) : (
<>
The client list currently names <Mono>{request.client}</Mono> {currentClient.text}.
</>
)}
</p>
)}
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
</div>
</>
);
}
@@ -0,0 +1,59 @@
/**
* The links out of one query's detail, shared by the persisted detail page and
* the in-place detail a streamed row opens.
*
* Both surfaces answer the same four follow-up questions, and both are read
* from an investigation that has a time range. Every link therefore carries
* absolute bounds: a link that said "recently" would show a different set of
* queries every time it was opened, which is the opposite of what linking to an
* incident is for.
*/
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { provenanceRelatedLink } from "./ProvenanceDetail";
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
import type { ActivitySearch } from "./search";
interface Props {
domain: string;
client: string;
/** The second this query was answered, which every window is centred on. */
ts: number;
/** The Activity search the reader came from; its bounds win over the defaults. */
origin: Pick<ActivitySearch, "since" | "until">;
}
export default function RelatedActions({ domain, client, ts, origin }: Props) {
const bounds = relatedBounds(ts, origin);
const window = diagnosticsBounds(ts);
return (
<>
<Link to="/activity/test" search={{ domain }} {...stylex.props(provenanceRelatedLink, shared.focusRing)}>
Test this domain against current policy
</Link>
<Link
to="/activity"
search={{ mode: "history", domain, client: undefined, blocked: undefined, ...bounds }}
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
>
All activity for this domain
</Link>
<Link
to="/activity"
search={{ mode: "history", client, domain: undefined, blocked: undefined, ...bounds }}
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
>
All activity from this client
</Link>
<Link
to="/diagnostics"
search={{ since: window.since, until: window.until }}
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
>
Diagnostics around this query
</Link>
</>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import type { QueryRow } from "@/lib/types";
import type { ClientNames } from "@/features/clients/clientNames";
import { queryRow } from "@/features/queries/provenanceFixture";
import { summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
const noNames: ClientNames = new Map();
function renderRow(overrides: Partial<QueryRow> = {}): HTMLTableRowElement {
const row: QuerySummary = summarizeRow(queryRow(1, overrides));
render(
<table>
<ActivityTableHead />
<tbody>
<tr data-testid="row">
<ActivityCells
row={row}
clientNames={noNames}
renderDomain={(id, children) => <a href={`/activity/queries/${id}`}>{children}</a>}
/>
</tr>
</tbody>
</table>,
);
return screen.getByTestId("row") as HTMLTableRowElement;
}
/** The cell under a header, read by its column name rather than its index. */
function cell(row: HTMLTableRowElement, column: (typeof ACTIVITY_COLUMNS)[number]): string {
const index = ACTIVITY_COLUMNS.indexOf(column);
return row.cells[index]?.textContent ?? "";
}
test("the head names the seven columns in order", () => {
render(
<table>
<ActivityTableHead />
</table>,
);
const headers = screen.getAllByRole("columnheader").map((header) => header.textContent);
expect(headers).toEqual(["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"]);
});
test("an allowed NOERROR row reads as the answer it got, with no badge", () => {
const row = renderRow({ blocked: false, rcode: 0, route_kind: "upstream", response_time_us: 1234 });
expect(cell(row, "Result")).toBe("NOERROR");
expect(cell(row, "Route")).toBe("Upstream");
expect(cell(row, "Duration")).toBe("1.2 ms");
expect(cell(row, "Type")).toBe("A");
});
test("a blocked row reads Blocked even though the client got NOERROR", () => {
const row = renderRow({ blocked: true, rcode: 0, route_kind: "blocked", policy_reason: "blocklist_domain" });
expect(cell(row, "Result")).toBe("Blocked");
expect(cell(row, "Route")).toBe("Blocked");
});
test("a SERVFAIL row names the code", () => {
const row = renderRow({ blocked: false, rcode: 2, route_kind: "upstream", response_time_us: null });
expect(cell(row, "Result")).toBe("SERVFAIL");
expect(cell(row, "Duration")).toBe("—");
});
test("a cache hit names the cache as the route", () => {
const row = renderRow({ blocked: false, rcode: 0, route_kind: "cache", cache_hit: true, upstream: "" });
expect(cell(row, "Route")).toBe("Cache");
expect(cell(row, "Result")).toBe("NOERROR");
});
test("an unassigned extended rcode keeps the numeric fallback, without the long form's parentheses", () => {
const row = renderRow({ blocked: false, rcode: 3841 });
expect(cell(row, "Result")).toBe("RCODE 3841");
});
test("a persisted row links its domain to the detail the caller chose", () => {
renderRow({ domain: "ads.example" });
expect(screen.getByRole("link", { name: "ads.example" }).getAttribute("href")).toBe("/activity/queries/1");
});
test("a streamed row reaches the renderer with a null id, and can render as plain text", () => {
render(
<table>
<tbody>
<tr data-testid="row">
<ActivityCells
row={{ ...summarizeRow(queryRow(1)), id: null }}
clientNames={noNames}
renderDomain={(id, children) => {
expect(id).toBeNull();
return children;
}}
/>
</tr>
</tbody>
</table>,
);
expect(screen.queryByRole("link")).toBeNull();
expect(screen.getByTestId("row").textContent).toContain("example.com");
});
test("every route kind has a compact label", () => {
expect(routeLabel("blocked")).toBe("Blocked");
expect(routeLabel("local")).toBe("Local");
expect(routeLabel("forward_zone")).toBe("Forward zone");
expect(routeLabel("upstream")).toBe("Upstream");
expect(routeLabel("cache")).toBe("Cache");
expect(routeLabel("rejected")).toBe("Rejected");
});
test("resultLabel is the pure form of the Result cell", () => {
expect(resultLabel({ blocked: true, rcode: 2 })).toBe("Blocked");
expect(resultLabel({ blocked: false, rcode: 5 })).toBe("REFUSED");
});
+171
View File
@@ -0,0 +1,171 @@
/**
* The seven columns of the Activity table: Time, Domain, Client, Type, Result,
* Route and Duration.
*
* The labels here are the compact forms a scanned table needs. The detail page
* keeps `provenanceCopy`'s long forms, which spell out the same facts with room
* for the rcode number and the "answered by" phrasing.
*
* The cells render both a stored row and a streamed event, so they know nothing
* about routes: the caller renders the Domain cell's contents and decides what,
* if anything, a row opens. A streamed row has no id the frame precedes its
* own insert and only the caller knows whether it has a surface for one.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatMicros, formatTime } from "@/lib/format";
import type { RouteKind } from "@/lib/types";
import { ClientName, type ClientNames } from "@/features/clients/clientNames";
import { rcodeShortName } from "@/features/queries/provenanceCopy";
import { qtypeName } from "@/features/queries/qtype";
import type { QuerySummary } from "@/features/queries/querySummary";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
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,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
nowrap: {
whiteSpace: "nowrap",
},
breakAll: {
wordBreak: "break-all",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
domainLink: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
/**
* The badge shape and its weight are the signal; the tint only says which
* kind of unhappy answer this was. A monochrome or colour-blind reading of
* the table still separates a blocked or failed row from a plain NOERROR
* one, which a hue alone would not.
*/
badge: {
display: "inline-block",
borderRadius: "0.25rem",
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 600,
},
blockedBadge: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
},
faultBadge: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(90.1% 0.076 70.697)" },
},
});
const ROUTE_LABELS: Record<RouteKind, string> = {
blocked: "Blocked",
local: "Local",
forward_zone: "Forward zone",
upstream: "Upstream",
cache: "Cache",
rejected: "Rejected",
};
/** The compact Route label. `Record` over the union, so a new kind fails `tsc`. */
export function routeLabel(kind: RouteKind): string {
return ROUTE_LABELS[kind];
}
/**
* The compact Result label. A block is the answer the operator asked nxdns for,
* so it wins over the rcode it was delivered as a blocked name answered with
* NOERROR and a zero address is still "Blocked". Everything else reads as the
* code the client saw.
*/
export function resultLabel(row: Pick<QuerySummary, "blocked" | "rcode">): string {
return row.blocked ? "Blocked" : rcodeShortName(row.rcode);
}
/**
* What a row's domain is wrapped in. The caller owns the routes, so it builds
* the element; the look stays here, as `activityDomainLink`, which the caller
* spreads onto the control itself a link's own colour beats one inherited
* from a wrapper. `id` is null for a streamed row, which history has no surface
* for and live opens from memory.
*/
export type DomainRenderer = (id: number | null, children: ReactNode) => ReactNode;
export const activityDomainLink = styles.domainLink;
export function ResultCellContent({ row }: { row: Pick<QuerySummary, "blocked" | "rcode"> }) {
const label = resultLabel(row);
if (row.blocked) return <span {...stylex.props(styles.badge, styles.blockedBadge)}>{label}</span>;
if (row.rcode !== 0) return <span {...stylex.props(styles.badge, styles.faultBadge)}>{label}</span>;
return <span {...stylex.props(styles.small, styles.muted)}>{label}</span>;
}
export function ActivityCells({
row,
clientNames,
renderDomain,
}: {
row: QuerySummary;
clientNames: ClientNames;
renderDomain: DomainRenderer;
}) {
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)}>
{renderDomain(row.id, row.domain)}
</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, styles.nowrap)}>
<ResultCellContent row={row} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
</td>
</>
);
}
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
export function ActivityTableHead() {
return (
<thead {...stylex.props(styles.head)}>
<tr>
{ACTIVITY_COLUMNS.map((column) => (
<th key={column} {...stylex.props(styles.th)}>
{column}
</th>
))}
</tr>
</thead>
);
}
@@ -0,0 +1,100 @@
import {
datetimeField,
datetimeLocalToUnix,
editDatetimeField,
resolveDatetimeField,
unixToDatetimeLocal,
} from "./datetime";
/**
* Every assertion here is about local time, so the zone has to be pinned. New
* York is the zone the DST cases are written for: the fold is 2024-11-03 01:30
* and the gap is 2024-03-10 02:30.
*/
// The app never reads `process`, so `src` is typed without node's globals; the
// test host is node, where assigning `TZ` re-reads the zone for `Date`.
declare const process: { env: Record<string, string | undefined> };
const originalTz = process.env["TZ"];
beforeAll(() => {
process.env["TZ"] = "America/New_York";
});
afterAll(() => {
process.env["TZ"] = originalTz;
});
/** 2024-06-01T12:34:56 EDT. */
const SUMMER = 1_717_259_696;
test("a non-zero-second instant round trips", () => {
expect(unixToDatetimeLocal(SUMMER)).toBe("2024-06-01T12:34:56");
expect(datetimeLocalToUnix("2024-06-01T12:34:56")).toBe(SUMMER);
});
test("text without seconds parses as :00", () => {
expect(datetimeLocalToUnix("2024-06-01T12:34")).toBe(datetimeLocalToUnix("2024-06-01T12:34:00"));
});
test("text that is not a datetime-local value names no instant", () => {
expect(datetimeLocalToUnix("")).toBeUndefined();
expect(datetimeLocalToUnix("yesterday")).toBeUndefined();
expect(datetimeLocalToUnix("2024-06-01")).toBeUndefined();
expect(datetimeLocalToUnix("2024-13-01T00:00:00")).toBeUndefined();
});
/** 2024-11-03 01:30 EDT and 01:30 EST: two instants, one wall clock. */
const FOLD_FIRST = 1_730_611_800;
const FOLD_SECOND = 1_730_615_400;
test("the fall-back fold gives two instants the same text", () => {
expect(unixToDatetimeLocal(FOLD_FIRST)).toBe("2024-11-03T01:30:00");
expect(unixToDatetimeLocal(FOLD_SECOND)).toBe("2024-11-03T01:30:00");
expect(datetimeLocalToUnix("2024-11-03T01:30:00")).toBe(FOLD_FIRST);
});
test("an untouched fold bound applies the instant it was seeded with, not a re-parse of its text", () => {
const field = datetimeField(FOLD_SECOND);
expect(field.text).toBe("2024-11-03T01:30:00");
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_SECOND });
});
test("an edited fold bound resolves to the first of the two instants, which is what its text says", () => {
const field = editDatetimeField(datetimeField(FOLD_SECOND), "2024-11-03T01:30:00");
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_FIRST });
});
test("a spring-forward time that exists on no clock is rejected rather than slid forward an hour", () => {
const field = editDatetimeField(datetimeField(undefined), "2024-03-10T02:30:00");
expect(datetimeLocalToUnix("2024-03-10T02:30:00")).toBe(1_710_055_800);
expect(unixToDatetimeLocal(1_710_055_800)).toBe("2024-03-10T03:30:00");
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "nonexistent" });
});
test("an edited bound round trips with non-zero seconds", () => {
const field = editDatetimeField(datetimeField(undefined), "2024-06-01T12:34:56");
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: SUMMER });
});
test("clearing an edited bound drops the filter", () => {
const field = editDatetimeField(datetimeField(SUMMER), "");
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
});
test("an unparseable edit is reported, never silently dropped", () => {
const field = editDatetimeField(datetimeField(undefined), "2024-06-32T99:99");
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "unparseable" });
});
test("an unset bound seeds an empty field that stays unset", () => {
const field = datetimeField(undefined);
expect(field.text).toBe("");
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
});
test("a fractional part on the seconds is parsed and dropped, not rejected", () => {
// jsdom, and any engine that sanitizes to the full grammar, hands the input
// back with milliseconds attached; a bound is a whole second either way.
const field = editDatetimeField(datetimeField(undefined), "2023-11-14T23:13:37.000");
const resolved = resolveDatetimeField(field);
expect(resolved).toEqual({ ok: true, value: datetimeLocalToUnix("2023-11-14T23:13:37") });
});
+95
View File
@@ -0,0 +1,95 @@
/**
* The bridge between a `datetime-local` input and the unix seconds the URL and
* the API speak.
*
* Local wall-clock text is lossy in a way unix seconds are not. Twice a year a
* fall-back fold gives two instants the same text, and a spring-forward gap
* gives an hour of text no instant at all. So the text is never the authority:
* a bound the operator did not touch is carried through as the number it
* already was, and a bound they did edit is accepted only when it survives a
* round trip unchanged.
*/
/** Zero-padded to the width the `datetime-local` grammar requires. */
function pad(value: number, width: number): string {
return String(value).padStart(width, "0");
}
/**
* Unix seconds the local wall-clock text a `datetime-local` input holds,
* always with seconds, because the inputs run at `step={1}`.
*/
export function unixToDatetimeLocal(unix: number): string {
const date = new Date(unix * 1000);
const day = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`;
const time = `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}`;
return `${day}T${time}`;
}
const DATETIME_LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?$/;
/**
* The text with its seconds spelled out, or undefined when it is not a
* `datetime-local` value at all. A browser omits `:00` seconds even at
* `step={1}`, so the canonical form is what a round trip compares against.
*
* The grammar allows a fractional part after the seconds and some engines emit
* one; a bound is a whole second here and on the wire, so it is parsed and then
* dropped rather than treated as text we do not recognise.
*/
function canonicalize(value: string): string | undefined {
const match = DATETIME_LOCAL.exec(value);
if (match === null) return undefined;
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6] ?? "00"}`;
}
/** Local wall-clock text → unix seconds, or undefined when it names no instant. */
export function datetimeLocalToUnix(value: string): number | undefined {
const canonical = canonicalize(value);
if (canonical === undefined) return undefined;
const ms = new Date(canonical).getTime();
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
}
/**
* One bound of the filter form: what the input shows, what the applied search
* carried, and whether the operator has touched it since.
*/
export interface DatetimeField {
text: string;
/** The applied value this field was seeded from, reused while `dirty` is false. */
original: number | undefined;
dirty: boolean;
}
export type DatetimeResolution =
{ ok: true; value: number | undefined } | { ok: false; reason: "unparseable" | "nonexistent" };
export function datetimeField(original: number | undefined): DatetimeField {
return { text: original === undefined ? "" : unixToDatetimeLocal(original), original, dirty: false };
}
export function editDatetimeField(field: DatetimeField, text: string): DatetimeField {
return { ...field, text, dirty: true };
}
/**
* The unix value this bound applies.
*
* An untouched field resolves to the number it was seeded with, never to a
* re-parse of its own text: the text of a fall-back instant names two of them,
* and re-parsing would silently move a bound the operator never edited.
*
* An edited field is parsed, then formatted back. A wall-clock time inside the
* spring-forward gap exists on no clock, and `Date` quietly slides it forward
* an hour; the round trip catches that and the caller reports it instead of
* filtering on an hour nobody asked for.
*/
export function resolveDatetimeField(field: DatetimeField): DatetimeResolution {
if (!field.dirty) return { ok: true, value: field.original };
if (field.text.trim() === "") return { ok: true, value: undefined };
const unix = datetimeLocalToUnix(field.text);
if (unix === undefined) return { ok: false, reason: "unparseable" };
if (unixToDatetimeLocal(unix) !== canonicalize(field.text)) return { ok: false, reason: "nonexistent" };
return { ok: true, value: unix };
}
@@ -0,0 +1,42 @@
/**
* The absolute bounds an investigation link carries.
*
* Every link out of a query detail is time-scoped on purpose: a relative window
* would answer a different question tomorrow than it does today, and the whole
* point of linking to an episode is that the link keeps showing that episode.
*/
import type { ActivitySearch } from "./search";
/** The five-minute window the redesign puts around one query. */
export const RELATED_WINDOW_SECONDS = 300;
export interface Bounds {
since: number;
until: number;
}
/**
* The bounds for "all activity for this domain/client", decided per bound.
*
* When the reader arrived from a bounded investigation, that bound is the one
* they are working in and it carries over. A bound they never set falls back to
* the five-minute window around this query never to no bound at all, which
* would answer with the whole retained history and lose the episode in it. The
* two bounds are decided separately, so a half-bounded origin keeps its half.
*/
export function relatedBounds(ts: number, origin: Pick<ActivitySearch, "since" | "until">): Bounds {
return {
since: origin.since ?? ts - RELATED_WINDOW_SECONDS,
until: origin.until ?? ts + RELATED_WINDOW_SECONDS,
};
}
/**
* The Diagnostics window around one query. Fixed at five minutes either side of
* the query, not inherited: the reader is asking what else was failing while
* this query was answered, which is a question about the query's own moment.
*/
export function diagnosticsBounds(ts: number): Bounds {
return { since: ts - RELATED_WINDOW_SECONDS, until: ts + RELATED_WINDOW_SECONDS };
}
@@ -0,0 +1,248 @@
import type { Provenance, QueryRow } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { RING_CAPACITY, mergeGap, pushRow, summaryOf, type LiveRow } from "./ringBuffer";
function streamed(key: number, ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): LiveRow {
return {
kind: "streamed",
key,
event: provenance({
...sections,
request: { time: ts, domain, ...sections.request },
route: { upstream: "udp://9.9.9.9:53", ...sections.route },
}),
};
}
function fetchedRow(id: number, ts: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
return queryRow(id, { ts, domain, upstream: "udp://9.9.9.9:53", ...overrides });
}
function counter(start = 100): () => number {
let n = start;
return () => ++n;
}
function domains(rows: LiveRow[]): string[] {
return rows.map((row) => summaryOf(row).domain);
}
describe("summaryOf", () => {
test("a streamed frame projects every summary field from the provenance it carries", () => {
const event: Provenance = provenance({
request: { time: 1700, domain: "ads.example", client: "192.0.2.11", qtype: 28 },
policy: { action: "block", reason: "blocklist_wildcard" },
route: { kind: "blocked", upstream: "" },
response: { duration_us: 42 },
});
expect(summaryOf({ kind: "streamed", key: 1, event })).toEqual({
id: null,
ts: 1700,
domain: "ads.example",
client_ip: "192.0.2.11",
qtype: 28,
blocked: true,
policy_reason: "blocklist_wildcard",
rcode: 0,
route_kind: "blocked",
response_time_us: 42,
cache_hit: null,
upstream: "",
});
});
test("a recovered row projects its stored fields and keeps its id", () => {
const row = queryRow(77, { domain: "news.example", cache_hit: true, policy_reason: "rule_allow_exact" });
expect(summaryOf({ kind: "recovered", key: 2, row })).toMatchObject({
id: 77,
domain: "news.example",
cache_hit: true,
policy_reason: "rule_allow_exact",
});
});
/**
* The guard the discriminated union exists for: a field added to the wire
* DTO must be either projected into the summary or consciously left to the
* detail page. A silent addition fails here rather than going unrendered.
*/
test("every provenance field is either projected or knowingly detail-only", () => {
const projected = [
"request.time",
"request.domain",
"request.client",
"request.qtype",
"policy.action",
"policy.reason",
"route.kind",
"route.upstream",
"response.duration_us",
];
const detailOnly = [
"request.qclass",
"group.id",
"group.name",
"policy.matched",
"policy.source_id",
"policy.source_name",
"rewrites.cname_target",
"rewrites.safe_search_target",
"route.forward_zone",
"response.rcode",
];
const leaves = Object.entries(provenance()).flatMap(([section, fields]) =>
Object.keys(fields as Record<string, unknown>).map((field) => `${section}.${field}`),
);
expect(leaves.sort()).toEqual([...projected, ...detailOnly].sort());
});
});
describe("pushRow", () => {
test("prepends newest-first", () => {
let rows: LiveRow[] = [];
rows = pushRow(rows, streamed(1, 10, "a.example"));
rows = pushRow(rows, streamed(2, 11, "b.example"));
expect(domains(rows)).toEqual(["b.example", "a.example"]);
});
test("drops the oldest beyond capacity", () => {
let rows: LiveRow[] = [];
for (let i = 0; i < 5; i++) rows = pushRow(rows, streamed(i, i, `d${i}.example`), 3);
expect(rows).toHaveLength(3);
expect(rows.map((r) => r.key)).toEqual([4, 3, 2]);
});
test("default capacity is 500", () => {
let rows: LiveRow[] = [];
for (let i = 0; i < RING_CAPACITY + 10; i++) rows = pushRow(rows, streamed(i, i, "x.example"));
expect(rows).toHaveLength(RING_CAPACITY);
});
});
describe("mergeGap", () => {
test("skips rows already in the buffer and counts only new ones", () => {
const buffer = [streamed(2, 100, "seen.example"), streamed(1, 99, "old.example")];
const fetched = [
fetchedRow(30, 102, "gap2.example"),
fetchedRow(29, 101, "gap1.example"),
fetchedRow(28, 100, "seen.example"),
];
const { rows, missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(2);
expect(domains(rows)).toEqual(["gap2.example", "gap1.example", "seen.example", "old.example"]);
});
test("no additions returns the buffer unchanged with missed 0", () => {
const buffer = [streamed(1, 100, "seen.example")];
const { rows, missed } = mergeGap(buffer, [fetchedRow(5, 100, "seen.example")], counter());
expect(missed).toBe(0);
expect(rows).toBe(buffer);
});
/**
* A household repeats itself: one client, one name, three lookups inside the
* same second. The stream delivered one of them before the connection broke,
* so the gap fetch must recover the other two rather than let the one row in
* the buffer stand for all three.
*/
test("repeated identical queries drop only as many rows as the buffer already holds", () => {
const buffer = [streamed(1, 100, "dup.example")];
const fetched = [
fetchedRow(12, 100, "dup.example"),
fetchedRow(11, 100, "dup.example"),
fetchedRow(10, 100, "dup.example"),
];
const { rows, missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(2);
expect(domains(rows)).toEqual(["dup.example", "dup.example", "dup.example"]);
const recoveredIds = rows.flatMap((row) => (row.kind === "recovered" ? [row.row.id] : []));
expect(new Set(recoveredIds).size).toBe(2);
});
test("a gap fetch that repeats the whole buffer adds nothing", () => {
const buffer = [streamed(2, 100, "dup.example"), streamed(1, 100, "dup.example")];
const fetched = [fetchedRow(12, 100, "dup.example"), fetchedRow(11, 100, "dup.example")];
const { rows, missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(0);
expect(rows).toBe(buffer);
});
/**
* Two queries of the same name from the same client in the same second are
* still separate facts when any stored column differs the record type or
* class, the response code, the policy that decided them, how long they took,
* the route taken. The gap fetch here returns the differing row *first* and
* the one the buffer already holds second, so an identity blind to the column
* would let the differing row consume the buffered occurrence: the buffered
* query would come back duplicated and the other would vanish, at an
* unchanged `missed`. Order is what exposes that the count alone is 1
* either way.
*
* `blocked` and `cache_hit` have no case of their own: the server derives
* them from `policy_action` and `route_kind`, so they cannot differ while
* everything else holds, and the two columns they follow are covered here.
*/
test.each([
{ column: "qtype", sections: { request: { qtype: 1 } }, held: { qtype: 1 }, differing: { qtype: 28 } },
{ column: "qclass", sections: { request: { qclass: 1 } }, held: { qclass: 1 }, differing: { qclass: 3 } },
{ column: "rcode", sections: { response: { rcode: 0 } }, held: { rcode: 0 }, differing: { rcode: 2 } },
{
column: "response_time_us",
sections: { response: { duration_us: 1234 } },
held: { response_time_us: 1234 },
differing: { response_time_us: 9999 },
},
{
column: "route_kind",
sections: { route: { kind: "upstream" } },
held: { route_kind: "upstream" },
differing: { route_kind: "forward_zone" },
},
{
column: "policy_action",
sections: { policy: { action: "allow" } },
held: { policy_action: "allow" },
differing: { policy_action: "not_evaluated" },
},
{
column: "policy_reason",
sections: { policy: { reason: "no_match" } },
held: { policy_reason: "no_match" },
differing: { policy_reason: "rule_allow_exact" },
},
] satisfies readonly {
column: string;
sections: Parameters<typeof provenance>[0];
held: Partial<QueryRow>;
differing: Partial<QueryRow>;
}[])("rows differing only in $column survive the gap merge", ({ sections, held, differing }) => {
const buffer = [streamed(1, 100, "dual.example", sections)];
const fetched = [fetchedRow(6, 100, "dual.example", differing), fetchedRow(5, 100, "dual.example", held)];
const { rows, missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(1);
expect(rows).toEqual([{ kind: "recovered", key: expect.any(Number), row: fetched[0] }, buffer[0]]);
});
test("recovered rows keep their id and take a fresh key", () => {
const { rows } = mergeGap([], [fetchedRow(77, 100, "gap.example")], counter(200));
const recovered = rows[0];
expect(recovered?.key).toBe(201);
expect(recovered?.kind).toBe("recovered");
expect(recovered !== undefined && recovered.kind === "recovered" ? recovered.row.id : null).toBe(77);
});
test("result is capped at capacity, keeping the newest", () => {
const buffer = [streamed(3, 300, "live.example")];
const fetched = [fetchedRow(2, 302, "g2.example"), fetchedRow(1, 301, "g1.example")];
const { rows, missed } = mergeGap(buffer, fetched, counter(), 2);
expect(missed).toBe(2);
expect(domains(rows)).toEqual(["g2.example", "g1.example"]);
});
test("merged rows stay sorted newest-first by ts", () => {
const buffer = [streamed(4, 105, "after-reopen.example"), streamed(3, 100, "before.example")];
const fetched = [fetchedRow(9, 103, "gap.example")];
const { rows } = mergeGap(buffer, fetched, counter());
expect(rows.map((row) => summaryOf(row).ts)).toEqual([105, 103, 100]);
});
});
+147
View File
@@ -0,0 +1,147 @@
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
import { summarizeEvent, summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
/**
* A row in the live buffer. `key` is a client-side monotonic counter, because
* neither arm has a stable identity of its own on arrival.
*
* The two arms are genuinely different facts, not two encodings of one. A
* streamed frame carries the full provenance of a query the server has not
* written yet; a row recovered by the reconnect gap-fetch is the stored summary
* of a query that *was* written, and cannot fabricate the provenance it never
* received. Only the recovered arm has a row id to link to.
*/
export type LiveRow = { key: number } & (
{ kind: "streamed"; event: LiveQueryEvent } | { kind: "recovered"; row: QueryRow }
);
/** The arm that carries its own provenance, and so its own detail surface. */
export type StreamedRow = Extract<LiveRow, { kind: "streamed" }>;
/** The flat cells both arms render, and the shared identity for gap dedupe. */
export function summaryOf(row: LiveRow): QuerySummary {
return row.kind === "streamed" ? summarizeEvent(row.event) : summarizeRow(row.row);
}
export const RING_CAPACITY = 500;
/** Prepend `row` (rows are newest-first) and drop the oldest beyond `capacity`. */
export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_CAPACITY): LiveRow[] {
const next = [row, ...rows];
return next.length > capacity ? next.slice(0, capacity) : next;
}
/**
* What the gap merge compares two queries by: the whole stored row bar its id.
*
* `since` on GET /api/queries is inclusive, so the re-sync fetch returns the
* last-seen row(s) again and the merge has to recognise them. The id cannot
* serve as the identity a streamed frame precedes its own insert and has none
* so the comparison is by value, and every stored column has to take part.
* Two queries alike in name, client and second but differing in class, rcode,
* the policy that decided them or the route taken are separate facts; if they
* hashed alike, the fetched row that does *not* match the buffered one would
* consume its occurrence, duplicating one query and losing the other.
*
* `QuerySummary` is the wrong basis for that: it is what the table renders, and
* it drops qclass and policy_action. `Omit<QueryRow, "id">`
* instead makes the compiler demand a derivation for every stored column, so a
* column added to the row cannot quietly fall out of the identity.
*/
type GapIdentity = Omit<QueryRow, "id">;
function identityOfRow(row: QueryRow): GapIdentity {
return {
ts: row.ts,
domain: row.domain,
client_ip: row.client_ip,
qtype: row.qtype,
qclass: row.qclass,
rcode: row.rcode,
blocked: row.blocked,
response_time_us: row.response_time_us,
cache_hit: row.cache_hit,
upstream: row.upstream,
policy_action: row.policy_action,
policy_reason: row.policy_reason,
route_kind: row.route_kind,
};
}
/**
* The same identity out of a live frame, which carries every stored column in
* its provenance. The columns the server derives rather than sends `blocked`
* and `cache_hit` come through `summarizeEvent` so that derivation keeps
* living in exactly one place.
*/
function identityOfEvent(event: LiveQueryEvent): GapIdentity {
const summary = summarizeEvent(event);
return {
ts: summary.ts,
domain: summary.domain,
client_ip: summary.client_ip,
qtype: summary.qtype,
qclass: event.request.qclass,
rcode: event.response.rcode,
blocked: summary.blocked,
response_time_us: summary.response_time_us,
cache_hit: summary.cache_hit,
upstream: summary.upstream,
policy_action: event.policy.action,
policy_reason: summary.policy_reason,
route_kind: event.route.kind,
};
}
function identityOf(row: LiveRow): GapIdentity {
return row.kind === "streamed" ? identityOfEvent(row.event) : identityOfRow(row.row);
}
/** Sorted keys so the hash cannot depend on the order the two arms happen to build their literals in. */
function signature(identity: GapIdentity): string {
return JSON.stringify(identity, Object.keys(identity).sort());
}
/**
* How many times each signature is already in the buffer. A signature is not
* unique: one client asking for one name twice within the same second is an
* ordinary household pattern, and the two queries are separate facts. Counting
* the occurrences lets the merge drop exactly as many fetched rows as the
* buffer already holds, instead of letting one buffered row hide all of them.
*/
function occurrences(rows: LiveRow[]): Map<string, number> {
const counts = new Map<string, number>();
for (const row of rows) {
const key = signature(identityOf(row));
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
/**
* Merge rows fetched for a reconnect gap (newest-first, from GET /api/queries)
* into the buffer. Each fetched row consumes one buffered occurrence of its
* signature and is skipped; the rest are genuinely missed and `missed` counts
* them. The result stays newest-first (stable sort by ts) and capped.
*/
export function mergeGap(
rows: LiveRow[],
fetched: QueryRow[],
nextKey: () => number,
capacity: number = RING_CAPACITY,
): { rows: LiveRow[]; missed: number } {
const buffered = occurrences(rows);
const added: LiveRow[] = [];
for (const row of fetched) {
const key = signature(identityOfRow(row));
const count = buffered.get(key) ?? 0;
if (count > 0) {
buffered.set(key, count - 1);
continue;
}
added.push({ kind: "recovered", row, key: nextKey() });
}
if (added.length === 0) return { rows, missed: 0 };
const merged = [...added, ...rows].sort((a, b) => summaryOf(b).ts - summaryOf(a).ts).slice(0, capacity);
return { rows: merged, missed: added.length };
}
+113
View File
@@ -0,0 +1,113 @@
import { validateActivitySearch, validateBlocked, validateMode, validateText, validateTimestamp } from "./search";
test("mode is the two-value union, defaulting to history", () => {
expect(validateMode("live")).toBe("live");
expect(validateMode("history")).toBe("history");
expect(validateMode(undefined)).toBe("history");
expect(validateMode("Live")).toBe("history");
expect(validateMode("")).toBe("history");
expect(validateMode(0)).toBe("history");
expect(validateMode(["live"])).toBe("history");
});
test("a bound is a safe integer or nothing at all", () => {
expect(validateTimestamp(1_700_000_000)).toBe(1_700_000_000);
expect(validateTimestamp(0)).toBe(0);
expect(validateTimestamp(-1)).toBe(-1);
});
test.each([
["a fraction", 1_700_000_000.5],
["Infinity", Number.POSITIVE_INFINITY],
["-Infinity", Number.NEGATIVE_INFINITY],
["NaN", Number.NaN],
["past the safe range", Number.MAX_SAFE_INTEGER + 1],
["1e21", 1e21],
["a numeric string", "1700000000"],
["an empty string", ""],
["null", null],
["undefined", undefined],
["a boolean", true],
["an array", [1_700_000_000]],
["a bigint", 1_700_000_000n],
])("a bound rejects %s", (_name, value) => {
expect(validateTimestamp(value)).toBeUndefined();
});
test("blocked keeps false, which is the allowed-only filter", () => {
expect(validateBlocked(true)).toBe(true);
expect(validateBlocked(false)).toBe(false);
});
test.each([
["the string true", "true"],
["the string false", "false"],
["1", 1],
["0", 0],
["null", null],
["undefined", undefined],
])("blocked rejects %s", (_name, value) => {
expect(validateBlocked(value)).toBeUndefined();
});
test("a text filter is trimmed, and an empty one is no filter", () => {
expect(validateText("ads.example")).toBe("ads.example");
expect(validateText(" ads.example ")).toBe("ads.example");
expect(validateText("")).toBeUndefined();
expect(validateText(" ")).toBeUndefined();
expect(validateText("\t\n")).toBeUndefined();
expect(validateText(42)).toBeUndefined();
expect(validateText(undefined)).toBeUndefined();
});
test("a whole search normalizes every field and drops nothing else in", () => {
expect(
validateActivitySearch({
mode: "live",
since: 1_700_000_000,
until: 1_700_000_600,
domain: " ads.example ",
client: "192.0.2.10",
blocked: false,
unknown: "kept out",
}),
).toEqual({
mode: "live",
since: 1_700_000_000,
until: 1_700_000_600,
domain: "ads.example",
client: "192.0.2.10",
blocked: false,
});
});
test("an empty search is history with no filters", () => {
expect(validateActivitySearch({})).toEqual({
mode: "history",
since: undefined,
until: undefined,
domain: undefined,
client: undefined,
blocked: undefined,
});
});
test("a search of junk applies nothing", () => {
expect(
validateActivitySearch({
mode: "HISTORY ",
since: "1700000000",
until: Number.POSITIVE_INFINITY,
domain: " ",
client: null,
blocked: "true",
}),
).toEqual({
mode: "history",
since: undefined,
until: undefined,
domain: undefined,
client: undefined,
blocked: undefined,
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* The Activity search parameters, validated as pure functions so the route's
* `validateSearch` stays a one-liner and every rejection is testable without a
* router.
*
* A search value arrives from a URL, from history state, or from a hand-typed
* link, so nothing about its type is given. Anything that is not exactly the
* value the API can filter on becomes `undefined`: an unbounded page is honest,
* a page filtered on a coerced guess is not.
*/
import type { QueriesFilter } from "@/lib/types";
export const ACTIVITY_MODES = ["history", "live"] as const;
export type ActivityMode = (typeof ACTIVITY_MODES)[number];
export interface ActivitySearch {
mode: ActivityMode;
since: number | undefined;
until: number | undefined;
domain: string | undefined;
client: string | undefined;
blocked: boolean | undefined;
}
/** History is the surface a bare `/activity` should open on: it answers questions. */
export function validateMode(value: unknown): ActivityMode {
return value === "live" ? "live" : "history";
}
/**
* A unix-second bound. `Number.isSafeInteger` is the whole test: it rejects a
* fraction, an infinity, a NaN and a magnitude past 2^53 in one step, and a
* string never passes, so `?since=now` cannot reach the API as garbage.
*/
export function validateTimestamp(value: unknown): number | undefined {
return Number.isSafeInteger(value) ? (value as number) : undefined;
}
/**
* The blocked filter. `false` is a real filter "allowed only" so it must
* survive; only a genuine boolean does, because `"false"` out of a URL parser
* that did not decode JSON would otherwise read as true.
*/
export function validateBlocked(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
/**
* A text filter, trimmed. An empty result becomes `undefined` rather than `""`:
* the server treats an empty filter as no filter, and a URL that showed
* `domain=` as applied state would claim a filter that is not filtering.
*/
export function validateText(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/**
* The API filter for a validated search, built field by field.
*
* Only the fields that are actually set are written, so an unfiltered request
* carries no keys at all: `GET /api/queries` rejects a parameter it does not
* know, and the infinite query's cache key is the filter object, so a key
* present-but-undefined and a key absent must not be two different windows onto
* the same rows. `mode` never appears it selects the surface, not the rows.
*/
export function queriesFilterOf(search: Omit<ActivitySearch, "mode">): QueriesFilter {
const filter: QueriesFilter = {};
if (search.domain !== undefined) filter.domain = search.domain;
if (search.client !== undefined) filter.client = search.client;
if (search.blocked !== undefined) filter.blocked = search.blocked;
if (search.since !== undefined) filter.since = search.since;
if (search.until !== undefined) filter.until = search.until;
return filter;
}
export function validateActivitySearch(search: Record<string, unknown>): ActivitySearch {
return {
mode: validateMode(search["mode"]),
since: validateTimestamp(search["since"]),
until: validateTimestamp(search["until"]),
domain: validateText(search["domain"]),
client: validateText(search["client"]),
blocked: validateBlocked(search["blocked"]),
};
}
@@ -1,6 +1,8 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { ApiError } from "@/lib/api";
import type { LiveQueryEvent, QueriesPage, QueryRow } from "@/lib/types";
import type { QueriesPage, QueryRow } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { summaryOf, type LiveRow } from "./ringBuffer";
import { FakeEventSource } from "./fakeEventSource";
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
@@ -8,41 +10,28 @@ afterEach(() => vi.unstubAllGlobals());
function stubLocationAssign() {
const assign = vi.fn();
vi.stubGlobal("location", { pathname: "/live", search: "", assign });
vi.stubGlobal("location", { pathname: "/activity", search: "?mode=live", assign });
return assign;
}
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: false,
upstream: "udp://9.9.9.9:53",
...overrides,
};
function frame(ts: number, domain: string): { data: string } {
const payload = provenance({
request: { time: ts, domain },
route: { upstream: "udp://9.9.9.9:53" },
});
return { data: JSON.stringify(payload) };
}
function fetchedRow(id: number, ts: number, domain: string): QueryRow {
return {
id,
ts,
domain,
client_ip: "192.0.2.10",
qtype: 1,
blocked: false,
block_reason: "",
response_time_us: 500,
cache_hit: false,
upstream: "udp://9.9.9.9:53",
};
return queryRow(id, { ts, domain, upstream: "udp://9.9.9.9:53" });
}
function domains(rows: LiveRow[]): string[] {
return rows.map((row) => summaryOf(row).domain);
}
const FULL_COVERAGE = { complete: true, available_since: 0 };
function setup(fetchSince?: (since: number) => Promise<QueriesPage>, probeSession?: () => Promise<unknown>) {
const sources: FakeEventSource[] = [];
const createEventSource = (url: string) => {
@@ -68,7 +57,7 @@ test("open then frames: rows newest-first with increasing keys", () => {
sources[0]!.emit("query", frame(1001, "b.example"));
});
const rows = hook.result.current.rows;
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
expect(domains(rows)).toEqual(["b.example", "a.example"]);
expect(rows[0]!.key).toBeGreaterThan(rows[1]!.key);
});
@@ -87,6 +76,7 @@ test("error then reopen re-syncs the gap since the last seen ts", async () => {
return Promise.resolve({
queries: [fetchedRow(9, 1002, "gap.example"), fetchedRow(8, since, "a.example")],
next_before: null,
coverage: FULL_COVERAGE,
});
});
const { sources, hook } = setup(fetchSince);
@@ -103,7 +93,7 @@ test("error then reopen re-syncs the gap since the last seen ts", async () => {
expect(fetchSince).toHaveBeenCalledWith(1000);
await waitFor(() => expect(hook.result.current.missed).toBe(1));
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["gap.example", "a.example"]);
expect(domains(hook.result.current.rows)).toEqual(["gap.example", "a.example"]);
act(() => hook.result.current.dismissMissed());
expect(hook.result.current.missed).toBeNull();
@@ -127,7 +117,7 @@ test("a 401 gap re-sync redirects to login instead of setting resyncFailed", asy
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
act(() => sources[0]!.emit("error"));
act(() => sources[0]!.emit("open"));
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
expect(hook.result.current.resyncFailed).toBe(false);
});
@@ -152,7 +142,7 @@ test("cap trip with an expired session redirects to login", async () => {
act(() => {
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
});
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
expect(probeSession).toHaveBeenCalledTimes(1);
});
@@ -193,7 +183,7 @@ test("a fatal rejection with an expired session redirects to login", async () =>
act(() => sources[0]!.failFatal());
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
expect(probeSession).toHaveBeenCalledTimes(1);
});
@@ -235,12 +225,12 @@ test("freeze keeps the display fixed while the buffer keeps filling", () => {
sources[0]!.emit("query", frame(1001, "b.example"));
sources[0]!.emit("query", frame(1002, "c.example"));
});
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["a.example"]);
expect(domains(hook.result.current.rows)).toEqual(["a.example"]);
expect(hook.result.current.liveCount).toBe(3);
act(() => hook.result.current.toggleFreeze());
expect(hook.result.current.frozen).toBe(false);
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["c.example", "b.example", "a.example"]);
expect(domains(hook.result.current.rows)).toEqual(["c.example", "b.example", "a.example"]);
});
test("stale sources are ignored after retry and closed on unmount", () => {
@@ -121,8 +121,12 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
} catch {
return;
}
lastSeenTsRef.current = payload.ts;
bufferRef.current = pushRow(bufferRef.current, { ...payload, key: ++keyRef.current });
lastSeenTsRef.current = payload.request.time;
bufferRef.current = pushRow(bufferRef.current, {
kind: "streamed",
event: payload,
key: ++keyRef.current,
});
setRows(bufferRef.current);
});
@@ -10,7 +10,9 @@ test("swallowMutationError drops an ApiError and rethrows anything else", () =>
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
const { rerender } = render(
<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={rejecting} onCancel={undefined} />,
);
const url = screen.getByLabelText("URL") as HTMLInputElement;
const name = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
@@ -22,7 +24,7 @@ test("a rejected submit leaves the typed values in place; a resolved one clears
expect(name.value).toBe("Example");
const resolving = vi.fn(() => Promise.resolve());
rerender(<BlocklistForm busy={false} error={null} onSubmit={resolving} onCancel={undefined} />);
rerender(<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={resolving} onCancel={undefined} />);
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
await waitFor(() => expect(url.value).toBe(""));
expect(name.value).toBe("");
@@ -1,8 +1,47 @@
import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const styles = stylex.create({
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
checkboxLabel: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
cancel: {
fontWeight: 500,
},
});
/**
* Drops the rejection the page already renders inline below the form. Anything
@@ -17,12 +56,14 @@ export function swallowMutationError(error: unknown): void {
interface BlocklistFormProps {
initial?: Blocklist;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: BlocklistInput) => Promise<void>;
onCancel?: () => void;
}
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [name, setName] = useState(initial?.name ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -43,10 +84,10 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
}
return (
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
<h2 className="text-lg font-medium">{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
<div>
<label htmlFor="blocklist-url" className="block text-sm font-medium">
<label htmlFor="blocklist-url" {...stylex.props(styles.fieldLabel)}>
URL
</label>
<input
@@ -55,11 +96,11 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
required
value={url}
onChange={(event) => setUrl(event.target.value)}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor="blocklist-name" className="block text-sm font-medium">
<label htmlFor="blocklist-name" {...stylex.props(styles.fieldLabel)}>
Name
</label>
<input
@@ -68,24 +109,33 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
required
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<label className="flex items-center gap-2 text-sm font-medium">
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
Enabled
</label>
<div className="flex items-center gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{initial === undefined ? "Add source" : "Save changes"}
</button>
{onCancel !== undefined && (
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
<button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancel, shared.focusRing)}
>
Cancel
</button>
)}
@@ -17,7 +17,9 @@ const BLOCKLISTS = {
last_updated: 1700000000,
domain_count: 1000,
wildcard_count: 10,
exception_count: 7,
skipped_regex_count: 3,
skipped_unsupported_count: 21,
checksum: "abc",
},
{
@@ -29,7 +31,9 @@ const BLOCKLISTS = {
last_updated: null,
domain_count: 0,
wildcard_count: 0,
exception_count: 0,
skipped_regex_count: 0,
skipped_unsupported_count: 0,
checksum: null,
},
],
@@ -41,14 +45,20 @@ const RESPONSES: Record<string, unknown> = {
};
let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];
beforeEach(() => {
clearRefreshStatus();
resolveUpdate = null;
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/blocklists/update" && init?.method === "POST") {
return new Promise<Response>((resolve) => {
resolveUpdate = resolve;
@@ -92,7 +102,9 @@ const SNAPSHOT = {
last_error: "",
domains: 1200,
wildcards: 12,
exceptions: 9,
skipped_regex: 4,
skipped_unsupported: 17,
},
],
};
@@ -106,8 +118,15 @@ test("renders the source table and the status empty state", async () => {
expect(screen.getByText("Suggested")).toBeTruthy();
expect(screen.getByText("1000")).toBeTruthy();
expect(screen.getByText("10")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("21")).toBeTruthy();
expect(screen.getByText("never")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
expect(enabledToggle.checked).toBe(true);
@@ -141,7 +160,9 @@ test("update now disables the button, then replaces the status section from the
last_error: "",
domains: 1200,
wildcards: 12,
exceptions: 9,
skipped_regex: 4,
skipped_unsupported: 17,
},
{
id: 2,
@@ -153,7 +174,9 @@ test("update now disables the button, then replaces the status section from the
last_error: "connect timed out",
domains: 0,
wildcards: 0,
exceptions: 0,
skipped_regex: 0,
skipped_unsupported: 0,
},
],
};
@@ -166,7 +189,10 @@ test("update now disables the button, then replaces the status section from the
expect(screen.getByText("connect timed out")).toBeTruthy();
expect(screen.getByText("1200")).toBeTruthy();
expect(screen.getByText("12")).toBeTruthy();
expect(screen.getByText("9")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy();
expect(screen.getByText("17")).toBeTruthy();
expect(screen.getAllByRole("columnheader", { name: "Skipped unsupported" })).toHaveLength(2);
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
// The store notifies one flush before the mutation's success state lands.
await screen.findByText(/Update completed/);
@@ -227,3 +253,27 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useRealTimers();
}
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleted).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that source", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
});
@@ -0,0 +1,269 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
blocklistCreateMutation,
blocklistDeleteMutation,
blocklistUpdateMutation,
blocklistsQuery,
blocklistsUpdateNowMutation,
} from "@/lib/queries";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
done: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.border,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.text,
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
export default function BlocklistsPage() {
const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
const create = useMutation(blocklistCreateMutation(queryClient));
const save = useMutation(blocklistUpdateMutation(queryClient));
const toggle = useMutation(blocklistUpdateMutation(queryClient));
const remove = useMutation(blocklistDeleteMutation(queryClient));
const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient));
const sources = useRefreshStatus();
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
// The refresh below re-fetches the sources the config already declares, so
// it stays live in file mode; every other control here writes config.
const readOnly = useReadOnlyConfig();
async function submitForm(input: BlocklistInput) {
if (editing === null) {
await create.mutateAsync(input);
} else {
await save.mutateAsync({ id: editing.id, input: { ...input, is_suggested: editing.is_suggested } });
setEditing(null);
}
}
function toggleEnabled(b: Blocklist) {
toggle.mutate({
id: b.id,
input: { url: b.url, name: b.name, enabled: !b.enabled, is_suggested: b.is_suggested },
});
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
const formError = editing === null ? create.error : save.error;
const tableError = remove.error ?? toggle.error;
return (
<section>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
<button
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
</div>
{updateNow.isSuccess && !updateNow.isPending && (
<p {...stylex.props(styles.done)} role="status">
Update completed; source status refreshed below.
</p>
)}
<InlineError error={updateNow.error} />
{blocklists.length === 0 ? (
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.url)} title={b.url}>
{b.url}
</span>
</td>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)}
{...stylex.props(shared.focusRing)}
/>
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{b.skipped_unsupported_count}
</td>
<td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => setEditing(b)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => setPendingDelete(b)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<p {...stylex.props(styles.note)}>
Both Skipped columns count lines nxdns read and did not take. Skipped regex lines are patterns
nxdns accepts only from you adopt one you trust as a regex rule. Skipped unsupported lines are
syntax nxdns cannot translate into a DNS decision: cosmetic element hiding, browser-only
modifiers. A skipped unsupported count that dwarfs the domain count usually means the list is
written for a browser extension, and its DNS or hosts variant will block more here.
</p>
</div>
)}
<InlineError error={tableError} />
<BlocklistForm
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
/>
<SourceStatusSection sources={sources} namesById={namesById} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete blocklist"
message={
pendingDelete === null
? ""
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
@@ -0,0 +1,134 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { SourceStatus } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
function formatAttempt(unixSeconds: number): string {
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
}
interface SourceStatusSectionProps {
sources: SourceStatus[] | null;
namesById: ReadonlyMap<number, string>;
}
const styles = stylex.create({
section: {
marginTop: "2rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "0.5rem",
overflowX: "auto",
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
url: {
marginTop: "0.125rem",
display: "block",
maxWidth: "16rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
/** Green has no token: a loaded source is the only success state in the app. */
loaded: {
color: {
default: "oklch(52.7% 0.154 150.069)",
"@media (prefers-color-scheme: dark)": "oklch(79.2% 0.209 151.711)",
},
},
failed: {
color: colors.danger,
},
absent: {
color: colors.textMuted,
},
});
export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
return (
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Source status</h2>
{sources === null ? (
<p {...stylex.props(styles.note)}>
No status snapshot yet run Update now to fetch status for every enabled source.
</p>
) : sources.length === 0 ? (
<p {...stylex.props(styles.note)}>The last update ran against no enabled sources.</p>
) : (
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Source</th>
<th {...stylex.props(shared.th)}>State</th>
<th {...stylex.props(shared.th)}>Last attempt</th>
<th {...stylex.props(shared.th)}>Last success</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last error</th>
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr key={source.id}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>
{namesById.get(source.id) ?? source.url}
</span>
<span {...stylex.props(styles.url)}>{source.url}</span>
</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(source.loaded ? styles.loaded : styles.failed)}>
{source.state}
</span>
</td>
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_attempt)}</td>
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_success)}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.domains}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.exceptions}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{source.skipped_unsupported}
</td>
<td {...stylex.props(shared.td)}>
{source.last_error === "" ? (
<span {...stylex.props(styles.absent)}></span>
) : (
<span {...stylex.props(styles.failed)}>{source.last_error}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
@@ -0,0 +1,104 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import Dialog from "@/ui/Dialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
client: Client;
groups: Group[];
onClose: () => void;
}
const styles = stylex.create({
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
dialogInput: {
marginTop: "0.25rem",
width: "100%",
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
},
});
export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientUpdateMutation(queryClient));
const readOnly = useReadOnlyConfig();
// Adopting the learned name as a typed one is the natural gesture, but only
// where the save can land: under file authority the PUT answers 403, and the
// file's declared name is the one that wins.
const [name, setName] = useState(client.name === "" && !readOnly ? client.learned_name : client.name);
const [groupId, setGroupId] = useState(client.group_id);
return (
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
<form
{...stylex.props(styles.form)}
onSubmit={(event) => {
event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
>
<label {...stylex.props(styles.fieldLabel)}>
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
{...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
/>
</label>
<Select
label="Group"
variant="compactField"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.actions)}>
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save
</button>
</div>
</form>
</Dialog>
);
}
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -18,6 +18,7 @@ const CLIENTS = {
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "laptop-1.lan",
group_id: 1,
group: "default",
hand_edited: true,
@@ -28,6 +29,7 @@ const CLIENTS = {
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "kids-tablet.lan",
group_id: 2,
group: "kids",
hand_edited: false,
@@ -95,6 +97,25 @@ test("renders the client table with group names and one hand-edited badge", asyn
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
});
test("a named row shows the typed name and hides the learned one", async () => {
await renderClientsPage(BASE);
expect(screen.getByText("laptop")).toBeTruthy();
expect(screen.queryByText("laptop-1.lan")).toBeNull();
});
test("an unnamed row shows the learned name with the learned affordance", async () => {
await renderClientsPage(BASE);
// The cell holds the learned name followed by the tag, so the match is on
// the containing span rather than on a bare text node.
const learned = screen.getByText(
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
);
// The affordance is text, not colour, so a screen reader announces it too.
expect(within(learned).getByText("learned")).toBeTruthy();
});
test("shows the DNS-activity empty state when there are no clients", async () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
@@ -106,10 +127,25 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
expect(dialog).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1");
// The dialog portals out of the table, so every field query is scoped to it.
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
// The RAC Select names its trigger with the value and then the label.
expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
});
test("the group picker offers every group and reports the choice", async () => {
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);
fireEvent.click(screen.getByRole("option", { name: "kids" }));
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
});
test("prefix editor starts clean and dirties on add", async () => {
+226
View File
@@ -0,0 +1,226 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
import { formatTime } from "@/lib/format";
import type { Client } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
/**
* Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts
* the file and is the one client DELETE the server answers 403 (ruling 7).
*/
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "48rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
right: {
textAlign: "right",
},
dash: {
color: colors.textMuted,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.primary,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
color: colors.primaryText,
},
confirmGroup: {
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
actionGroup: {
display: "inline-flex",
gap: "0.5rem",
},
note: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
/**
* The accessible name of the actions column, kept out of the visual table
* without leaving the accessibility tree.
*/
});
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
const { data: groups } = useSuspenseQuery(groupsQuery());
const queryClient = useQueryClient();
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const readOnly = useReadOnlyConfig();
return (
<section>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
{clients.length === 0 ? (
<p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries there is
nothing to create by hand.
</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
<th {...stylex.props(styles.cell)}>IP</th>
<th {...stylex.props(styles.cell)}>Name</th>
<th {...stylex.props(styles.cell)}>Group</th>
<th {...stylex.props(styles.cell)}>First seen</th>
<th {...stylex.props(styles.cell)}>Last seen</th>
<th {...stylex.props(styles.cell)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{clients.map((client) => (
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
<td {...stylex.props(styles.cell)}>
{client.name !== "" ? (
client.name
) : client.learned_name !== "" ? (
<span {...stylex.props(shared.learnedName)}>
{client.learned_name}
<span {...stylex.props(shared.learnedTag)}>learned</span>
</span>
) : (
<span {...stylex.props(styles.dash)}></span>
)}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td>
<td {...stylex.props(styles.cell)}>{client.group}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.right)}>
{confirmingId === client.id ? (
<span {...stylex.props(styles.confirmGroup)}>
<span {...stylex.props(styles.note)}>
Deleted clients re-materialize on their next DNS query.
</span>
<button
type="button"
onClick={() => {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
</button>
</span>
) : (
<span {...stylex.props(styles.actionGroup)}>
<button
type="button"
onClick={() => setEditing(client)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => setConfirmingId(client.id)}
disabled={readOnly && client.hand_edited}
title={
readOnly && client.hand_edited
? DECLARED_CLIENT_NOTE
: undefined
}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<InlineError error={deleteMutation.error} />
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
<PrefixesEditor prefixes={prefixes} groups={groups} />
</section>
);
}
@@ -1,17 +1,87 @@
import { useReducer, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
prefixes: ClientPrefix[];
groups: Group[];
}
const styles = stylex.create({
section: {
marginTop: "2.5rem",
},
heading: {
fontSize: "1.25rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
rows: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "1rem",
listStyleType: "none",
padding: 0,
},
row: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
prefixInput: {
width: "13rem",
},
priorityInput: {
width: "5rem",
},
removeButton: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: "transparent",
paddingInline: "0.5rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
validation: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
actions: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
},
});
export default function PrefixesEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
@@ -19,6 +89,8 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const [validation, setValidation] = useState<string | null>(null);
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig();
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
const save = () => {
const problem = firstProblem(state.rows);
@@ -30,18 +102,18 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
};
return (
<section className="mt-10">
<h2 className="text-xl font-semibold">Client prefixes</h2>
<p className="mt-1 text-sm text-zinc-500">
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
<p {...stylex.props(styles.intro)}>
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
match wins.
</p>
{state.rows.length === 0 ? (
<p className="mt-4 text-sm text-zinc-500">No prefixes configured.</p>
<p {...stylex.props(styles.empty)}>No prefixes configured.</p>
) : (
<ul className="mt-4 space-y-2">
<ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => (
<li key={index} className="flex flex-wrap items-center gap-2">
<li key={index} {...stylex.props(styles.row)}>
<input
type="text"
aria-label={`Prefix ${index + 1}`}
@@ -50,22 +122,17 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
}
className={`${smallInputClass} w-52`}
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/>
<select
<Select
aria-label={`Group for prefix ${index + 1}`}
variant="inline"
value={String(row.group_id)}
onChange={(event) =>
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
onChange={(value) =>
dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
}
className={smallInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
options={groupOptions}
/>
<input
type="text"
inputMode="numeric"
@@ -75,12 +142,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
}
className={`${smallInputClass} w-20`}
{...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
/>
<button
type="button"
onClick={() => dispatch({ type: "remove", index })}
className={`rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 ${focusRing} dark:border-zinc-700 dark:text-red-400`}
{...stylex.props(styles.removeButton, shared.focusRing)}
>
Remove
</button>
@@ -89,24 +156,25 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
</ul>
)}
{validation !== null && (
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-400">
<p role="alert" {...stylex.props(styles.validation)}>
{validation}
</p>
)}
<InlineError error={mutation.error} />
<div className="mt-4 flex gap-2">
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
className={buttonClass}
{...stylex.props(shared.button, shared.focusRing)}
>
Add prefix
</button>
<button
type="button"
onClick={save}
disabled={!dirty || mutation.isPending}
className={primaryButtonClass}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save prefixes
</button>
@@ -117,7 +185,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
className={buttonClass}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard changes
</button>
@@ -0,0 +1,63 @@
/**
* 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],
);
}
/**
* The name the loaded list gives this address right now, or null when it gives
* none. Callers that must distinguish "named" from "bare address" rather than
* just render whichever applies read this instead of re-deriving precedence.
*/
export function clientLabel(ip: string, names: ClientNames): { text: string; learned: boolean } | null {
const client = names.get(ip);
if (client === undefined) return null;
if (client.name !== "") return { text: client.name, learned: false };
if (client.learned_name !== "") return { text: client.learned_name, learned: true };
return null;
}
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
const label = clientLabel(ip, names);
if (label === null) 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.
return (
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
{label.text}
</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",
@@ -15,6 +18,7 @@ const RESPONSES: Record<string, unknown> = {
cached: 100,
clients: 7,
avg_response_time_us: 2345,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=24h": {
period: "24h",
@@ -26,6 +30,7 @@ const RESPONSES: Record<string, unknown> = {
{ ts: 1800, queries: 40, blocked: 0, cached: 0 },
{ ts: 3600, queries: 0, blocked: 0, cached: 0 },
],
coverage: { complete: true, available_since: 0 },
},
"/api/stats?period=1h": {
period: "1h",
@@ -36,6 +41,7 @@ const RESPONSES: Record<string, unknown> = {
cached: 0,
clients: 2,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=1h": {
period: "1h",
@@ -43,6 +49,7 @@ const RESPONSES: Record<string, unknown> = {
until: 3600,
bucket_seconds: 60,
buckets: [],
coverage: { complete: true, available_since: 0 },
},
"/api/health": {
status: "degraded",
@@ -58,32 +65,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 +184,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 +197,31 @@ 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("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 +236,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 +247,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();
});
@@ -0,0 +1,173 @@
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards";
import TimeseriesChart from "./TimeseriesChart";
import UpstreamHealthTable from "./UpstreamHealthTable";
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
titleRow: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
period: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
/** Dynamic: the caller sizes the placeholder to the widget it stands in for. */
skeletonHeight: (height: number) => ({ height }),
skeleton: {
borderRadius: "0.25rem",
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
},
},
/** The chart takes two thirds beside the storage card from `lg`, one column below. */
panelGrid: {
display: "grid",
gap: "1rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 1024px)": "2fr 1fr",
},
},
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
});
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
{...stylex.props(
styles.period,
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
>
{option}
</button>
))}
</div>
);
}
function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
}
export default function DashboardPage() {
const [period, setPeriod] = useState<Period>("24h");
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
const health = useQuery(healthQuery());
const upstreamHealth = useQuery({ ...upstreamHealthQuery(period), placeholderData: keepPreviousData });
return (
<section {...stylex.props(styles.page)}>
<div {...stylex.props(styles.titleRow)}>
<h1 {...stylex.props(styles.heading)}>Dashboard</h1>
<PeriodPicker period={period} onChange={setPeriod} />
</div>
{health.data !== undefined && <HealthBanners health={health.data} />}
{stats.isError ? (
<InlineError error={stats.error} onRetry={() => void stats.refetch()} />
) : stats.data === undefined ? (
<Skeleton height={76} />
) : (
<StatCards stats={stats.data} />
)}
{/* One notice for the period: the chart is judged against the same
aligned lower bound as the totals, so it would say the same thing. */}
{stats.data !== undefined && <CoverageNotice coverage={stats.data.coverage} />}
<div {...stylex.props(styles.panelGrid)}>
<section {...stylex.props(styles.panel)}>
<h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
{timeseries.isError ? (
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
) : timeseries.data === undefined ? (
<Skeleton height={240} />
) : (
<TimeseriesChart data={timeseries.data} />
)}
</section>
{health.data === undefined ? <Skeleton height={160} /> : <DiskCard disk={health.data.disk} />}
</div>
{upstreamHealth.isError ? (
<InlineError error={upstreamHealth.error} onRetry={() => void upstreamHealth.refetch()} />
) : upstreamHealth.data === undefined ? (
<Skeleton height={120} />
) : (
<UpstreamHealthTable health={upstreamHealth.data} />
)}
</section>
);
}
+99
View File
@@ -0,0 +1,99 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
heading: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
badge: {
borderRadius: "0.25rem",
paddingInline: "0.5rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/**
* The badge fills are their own three-step scale, not the `danger`/`warn`
* banner tokens: they read as a tinted chip against a raised card, where a
* banner fill would be too heavy.
*/
ok: {
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
warn: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
critical: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
row: {
display: "flex",
justifyContent: "space-between",
},
term: {
color: colors.textMuted,
},
});
function stateStyle(state: Health["disk"]["state"]) {
if (state === "critical") return styles.critical;
return state === "warn" ? styles.warn : styles.ok;
}
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
return (
<section {...stylex.props(styles.card)}>
{/* Live state, unlike the ranged widgets around it; the title says so
rather than a section rule the picker would have to disown. */}
<h2 {...stylex.props(styles.heading)}>
Storage now
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
</h2>
<dl {...stylex.props(styles.list)}>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Free</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Database</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Logs</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
</div>
</dl>
</section>
);
}
@@ -1,13 +1,38 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
stack: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
banner: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
warn: {
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
},
critical: {
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
color: colors.dangerText,
},
});
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
const classes =
tone === "critical"
? "border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200"
: "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200";
return (
<p role="alert" className={`rounded border px-4 py-2 text-sm ${classes}`}>
<p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
{children}
</p>
);
@@ -39,5 +64,5 @@ export default function HealthBanners({ health }: { health: Health }) {
);
}
if (banners.length === 0) return null;
return <div className="space-y-2">{banners}</div>;
return <div {...stylex.props(styles.stack)}>{banners}</div>;
}
@@ -0,0 +1,85 @@
import * as stylex from "@stylexjs/stylex";
import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
},
},
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
value: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
detail: {
marginLeft: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
function percentOf(part: number, total: number): string | null {
if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`;
}
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
return (
<div {...stylex.props(styles.card)}>
<dt {...stylex.props(styles.label)}>{label}</dt>
<dd>
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd>
</div>
);
}
export default function StatCards({ stats }: { stats: StatsTotals }) {
return (
<dl {...stylex.props(styles.grid)}>
<Card label="Queries" value={numberFormat.format(stats.queries)} />
<Card
label="Blocked"
value={numberFormat.format(stats.blocked)}
detail={percentOf(stats.blocked, stats.queries)}
/>
<Card
label="Cached"
value={numberFormat.format(stats.cached)}
detail={percentOf(stats.cached, stats.queries)}
/>
<Card label="Clients" value={numberFormat.format(stats.clients)} />
<Card
label="Avg response"
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
/>
</dl>
);
}
@@ -0,0 +1,52 @@
import { render, screen, within } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import type { StatsTimeseries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import TimeseriesChart from "./TimeseriesChart";
const SINCE = 1_700_000_000;
function timeseries(bucketCount: number): StatsTimeseries {
return {
period: "24h",
since: SINCE,
until: SINCE + bucketCount * 1800,
bucket_seconds: 1800,
coverage: { complete: true, available_since: SINCE },
buckets: Array.from({ length: bucketCount }, (_, i) => ({
ts: SINCE + i * 1800,
queries: i + 1,
blocked: 1,
cached: 1,
})),
};
}
/** The element wearing the shared hidden style, found by its compiled classes. */
function hiddenElement(container: HTMLElement): Element | null {
const classes = stylex.props(shared.srOnly).className?.split(" ").filter(Boolean) ?? [];
expect(classes.length).toBeGreaterThan(0);
return container.querySelector(classes.map((name) => `.${name}`).join(""));
}
test("the data table is the SVG's accessible equivalent", () => {
render(<TimeseriesChart data={timeseries(3)} />);
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
const table = screen.getByRole("table", { name: "Queries per time bucket" });
expect(within(table).getAllByRole("row").length).toBe(4);
});
/**
* `overflow` does not apply to a table box and `height` on one is a minimum, so
* the hidden style has to sit on a block container wrapping the table. Worn by
* the table itself it clips the paint but not the layout, and 48 invisible rows
* push the document's scroll height a screen past the app shell.
*/
test("the hidden data table is clipped by a block wrapper, not by the table itself", () => {
const { container } = render(<TimeseriesChart data={timeseries(48)} />);
const hidden = hiddenElement(container);
expect(hidden?.tagName).toBe("DIV");
expect(hidden?.querySelector("table")).not.toBeNull();
});
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { StatsTimeseries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
// Series colors validated for CVD separation and 3:1 surface contrast in both
@@ -14,6 +17,108 @@ const SERIES = [
const CHART_HEIGHT = 240;
const FALLBACK_WIDTH = 640;
const styles = stylex.create({
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: CHART_HEIGHT,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
chartRoot: {
position: "relative",
},
tooltip: {
pointerEvents: "none",
position: "absolute",
top: "0.5rem",
zIndex: 10,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
/** Dynamic: the tooltip flips to whichever side of the bar has room. */
tooltipLeft: (left: number) => ({ left, right: null }),
tooltipRight: (right: number) => ({ left: null, right }),
tooltipTitle: {
fontWeight: 500,
},
tooltipList: {
display: "flex",
flexDirection: "column",
gap: "0.125rem",
marginTop: "0.25rem",
},
tooltipRow: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
},
tooltipTerm: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
color: colors.textMuted,
},
swatch: {
display: "inline-block",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
swatchSmall: {
width: "0.5rem",
height: "0.5rem",
},
swatchLarge: {
width: "0.625rem",
height: "0.625rem",
},
gridLine: {
stroke: colors.border,
},
axisLine: {
stroke: colors.borderStrong,
},
axisLabel: {
fill: colors.textMuted,
fontSize: "10px",
},
/** The hairline separating touching segments is the page ground, not a colour. */
segment: {
stroke: colors.surface,
},
legend: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
legendItem: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
});
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
@@ -46,28 +151,29 @@ function barSummary(bar: BarLayout): string {
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
const centerX = bar.slot.x + bar.slot.width / 2;
const leftHalf = centerX < chartWidth / 2;
const side = leftHalf
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
: styles.tooltipRight(chartWidth - centerX + 8);
return (
<div
className="pointer-events-none absolute top-2 z-10 rounded border border-zinc-200 bg-white px-3 py-2 text-xs shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
style={leftHalf ? { left: Math.min(centerX + 8, chartWidth - 160) } : { right: chartWidth - centerX + 8 }}
>
<div className="font-medium">{formatTime(bar.bucket.ts)}</div>
<dl className="mt-1 space-y-0.5">
<div className="flex justify-between gap-4">
<dt className="text-zinc-500">Queries</dt>
<dd className="tabular-nums">{bar.bucket.queries}</dd>
<div {...stylex.props(styles.tooltip, side)}>
<div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
<dl {...stylex.props(styles.tooltipList)}>
<div {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
<dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
</div>
{SERIES.map((series) => (
<div key={series.key} className="flex items-center justify-between gap-4">
<dt className="flex items-center gap-1.5 text-zinc-500">
<div key={series.key} {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>
<span
aria-hidden="true"
className="inline-block size-2 rounded-xs"
style={{ backgroundColor: series.color }}
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
/>
{series.label}
</dt>
<dd className="tabular-nums">{series.key === "other" ? bar.other : bar.bucket[series.key]}</dd>
<dd {...stylex.props(shared.tabularNums)}>
{series.key === "other" ? bar.other : bar.bucket[series.key]}
</dd>
</div>
))}
</dl>
@@ -82,11 +188,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
return (
<div
ref={containerRef}
className="flex items-center justify-center rounded border border-dashed border-zinc-300 text-sm text-zinc-500 dark:border-zinc-700"
style={{ height: CHART_HEIGHT }}
>
<div ref={containerRef} {...stylex.props(styles.empty)}>
No queries in this period.
</div>
);
@@ -97,7 +199,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
return (
<div ref={containerRef} className="relative">
<div ref={containerRef} {...stylex.props(styles.chartRoot)}>
<svg
role="img"
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
@@ -113,14 +215,14 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width}
y1={tick.y}
y2={tick.y}
className="stroke-zinc-200 dark:stroke-zinc-800"
{...stylex.props(styles.gridLine)}
/>
<text
x={layout.plot.x - 6}
y={tick.y}
textAnchor="end"
dominantBaseline="middle"
className="fill-zinc-500 text-[10px] tabular-nums"
{...stylex.props(styles.axisLabel, shared.tabularNums)}
>
{compact.format(tick.value)}
</text>
@@ -131,7 +233,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width}
y1={baseline}
y2={baseline}
className="stroke-zinc-300 dark:stroke-zinc-700"
{...stylex.props(styles.axisLine)}
/>
{layout.xTicks.map((tick) => (
<text
@@ -139,7 +241,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x={tick.x}
y={baseline + 14}
textAnchor="middle"
className="fill-zinc-500 text-[10px]"
{...stylex.props(styles.axisLabel)}
>
{formatTick(tick.ts, data.bucket_seconds)}
</text>
@@ -157,8 +259,8 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
width={rect.width}
height={rect.height}
fill={series.color}
className="stroke-zinc-50 dark:stroke-zinc-950"
strokeWidth={rect.width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/>
);
})}
@@ -179,41 +281,42 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
))}
</svg>
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<ul {...stylex.props(styles.legend)}>
{SERIES.map((series) => (
<li key={series.key} className="flex items-center gap-1.5">
<li key={series.key} {...stylex.props(styles.legendItem)}>
<span
aria-hidden="true"
className="inline-block size-2.5 rounded-xs"
style={{ backgroundColor: series.color }}
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
/>
{series.label}
</li>
))}
</ul>
<table className="sr-only">
<caption>Queries per time bucket</caption>
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Queries</th>
<th scope="col">Blocked</th>
<th scope="col">Cached</th>
<th scope="col">Other</th>
</tr>
</thead>
<tbody>
{layout.bars.map((bar) => (
<tr key={bar.bucket.ts}>
<th scope="row">{formatTime(bar.bucket.ts)}</th>
<td>{bar.bucket.queries}</td>
<td>{bar.bucket.blocked}</td>
<td>{bar.bucket.cached}</td>
<td>{bar.other}</td>
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>Queries per time bucket</caption>
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Queries</th>
<th scope="col">Blocked</th>
<th scope="col">Cached</th>
<th scope="col">Other</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{layout.bars.map((bar) => (
<tr key={bar.bucket.ts}>
<th scope="row">{formatTime(bar.bucket.ts)}</th>
<td>{bar.bucket.queries}</td>
<td>{bar.bucket.blocked}</td>
<td>{bar.bucket.cached}</td>
<td>{bar.other}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,165 @@
import { render, screen, within } from "@testing-library/react";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
import UpstreamHealthTable from "./UpstreamHealthTable";
const NOW_S = 1_700_000_000;
const ZERO: UpstreamPeriodStats = {
attempts: 0,
successes: 0,
failures: 0,
success_rate: null,
last_failure_at: null,
last_failure_error: null,
};
function period(overrides: Partial<UpstreamPeriodStats> = {}): UpstreamPeriodStats {
return {
attempts: 100,
successes: 90,
failures: 10,
success_rate: 0.9,
last_failure_at: NOW_S - 12_600,
last_failure_error: "Timeout",
...overrides,
};
}
function entry(overrides: Partial<UpstreamHealthEntry> = {}): UpstreamHealthEntry {
return {
url: "https://dns.example/dns-query",
enabled: true,
available: true,
period: period(),
...overrides,
};
}
function renderTable(upstreams: UpstreamHealthEntry[], overrides: Partial<UpstreamHealth> = {}) {
const health: UpstreamHealth = {
period: "24h",
since: NOW_S - 86_400,
until: NOW_S,
available: upstreams.filter((upstream) => upstream.available).length,
total: upstreams.length,
complete: true,
upstreams,
...overrides,
};
render(<UpstreamHealthTable health={health} />);
}
function rowOf(url: string): HTMLElement {
const cell = screen.getByText(url);
const row = cell.closest("tr");
if (row === null) throw new Error(`no row for ${url}`);
return row;
}
test("the ranged columns sit under a header naming the selected period", () => {
renderTable([entry()]);
expect(screen.getByRole("columnheader", { name: "Selected period · 24h" })).toBeTruthy();
for (const name of ["Upstream", "Status now", "Attempts", "Failures", "Success rate"]) {
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
}
// The unranged yes/no pair the ranged table replaced.
expect(screen.queryByRole("columnheader", { name: "Enabled" })).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Available" })).toBeNull();
});
test("failure detail is the Diagnostics page's job; the card never shows it", () => {
renderTable([entry()]);
expect(screen.queryByRole("columnheader", { name: "Last failure" })).toBeNull();
expect(screen.queryByText(/Timeout/)).toBeNull();
expect(screen.queryByText(/ago$/)).toBeNull();
});
test("status now is one word from live state, not from the window", () => {
renderTable([
entry({ url: "https://a.example/dns-query" }),
entry({ url: "https://b.example/dns-query", available: false }),
entry({ url: "https://c.example/dns-query", enabled: false, available: false }),
]);
expect(within(rowOf("https://a.example/dns-query")).getByText("Available")).toBeTruthy();
expect(within(rowOf("https://b.example/dns-query")).getByText("Backing off")).toBeTruthy();
expect(within(rowOf("https://c.example/dns-query")).getByText("Disabled")).toBeTruthy();
});
test("a window with no attempts renders an em-dash and never a perfect rate", () => {
renderTable([entry({ period: ZERO })]);
const cells = within(rowOf("https://dns.example/dns-query")).getAllByRole("cell");
expect(cells.map((cell) => cell.textContent)).toEqual([
"https://dns.example/dns-query",
"Available",
"0",
"0",
"—",
]);
expect(screen.queryByText("100.0%")).toBeNull();
expect(screen.queryByText("0.0%")).toBeNull();
});
test("the card says so when every upstream was idle in the window", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry({ period: ZERO })]);
expect(screen.getByText("No upstream attempts in this period.")).toBeTruthy();
});
test("one upstream with attempts keeps the idle message away", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry()]);
expect(screen.queryByText("No upstream attempts in this period.")).toBeNull();
});
test("an incomplete window carries a note; a complete one claims nothing", () => {
renderTable([entry()], { complete: false });
expect(screen.getByText(/history incomplete/i)).toBeTruthy();
});
test("a complete window shows no completeness text at all", () => {
renderTable([entry()], { complete: true });
expect(screen.queryByText(/history incomplete/i)).toBeNull();
expect(screen.queryByText(/complete/i)).toBeNull();
});
test("an empty pool says so instead of drawing a table", () => {
renderTable([]);
expect(screen.getByText("No upstreams configured.")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});
test("a rate a hair under perfect never rounds up to 100.0% while failures stand", () => {
// The real row that produced this: 12,698 attempts, 2 failures, 99.984%.
renderTable([
entry({
period: period({ attempts: 12_698, successes: 12_696, failures: 2, success_rate: 12_696 / 12_698 }),
}),
]);
expect(screen.queryByText("100.0%")).toBeNull();
expect(screen.getByText("99.9%")).toBeTruthy();
});
test("a rate a hair above nothing never rounds down to 0.0% while successes stand", () => {
renderTable([
entry({
period: period({ attempts: 12_698, successes: 2, failures: 12_696, success_rate: 2 / 12_698 }),
}),
]);
expect(screen.queryByText("0.0%")).toBeNull();
expect(screen.getByText("0.1%")).toBeTruthy();
});
test("a window with no failures at all still reads 100.0%", () => {
renderTable([entry({ period: period({ attempts: 500, successes: 500, failures: 0, success_rate: 1 }) })]);
expect(screen.getByText("100.0%")).toBeTruthy();
});
@@ -0,0 +1,226 @@
import * as stylex from "@stylexjs/stylex";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
},
heading: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
paddingInline: "1rem",
paddingTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
count: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
empty: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
note: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tableWrap: {
overflowX: "auto",
},
table: {
marginTop: "0.5rem",
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/**
* The two live columns are left outside the span: everything under it answers
* for the selected window, and nothing else on this card does.
*/
groupRow: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
groupHead: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "1rem",
paddingBottom: "0.25rem",
textAlign: "center",
fontWeight: 500,
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
textAlign: "left",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
th: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontWeight: 500,
},
thRight: {
textAlign: "right",
},
/** No hairline under the last row: the card border already closes the table. */
row: {
borderBottomWidth: { default: 1, ":last-child": 0 },
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
},
cellRight: {
textAlign: "right",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
bad: {
color: colors.danger,
},
});
/** Live pool state in one word. Configuration first: a disabled upstream is not backing off. */
function statusNow(upstream: UpstreamHealthEntry): "Available" | "Backing off" | "Disabled" {
if (!upstream.enabled) return "Disabled";
return upstream.available ? "Available" : "Backing off";
}
/**
* `success_rate` is null exactly when the window holds no attempt, and that must
* not read as perfect reliability hence the em-dash rather than `100.0%`.
*
* One decimal place cannot hold 12,696 of 12,698: it rounds to `100.0%`, and the
* row then claims perfection beside a failure count of 2. Neither endpoint may
* be reached by rounding only by actually having no failure, or no success.
*/
function successRate(period: UpstreamPeriodStats): string {
if (period.success_rate === null) return "—";
const rounded = period.success_rate * 100;
if (rounded > 99.9 && period.failures > 0) return "99.9%";
if (rounded < 0.1 && period.successes > 0) return "0.1%";
return `${rounded.toFixed(1)}%`;
}
/**
* The dashboard answers availability only. Failure detail what failed, when,
* and how often is the Diagnostics page's job, so `last_failure_at` and
* `last_failure_error` are read there rather than repeated in this row.
*/
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
const idle = health.upstreams.length > 0 && health.upstreams.every(({ period }) => period.attempts === 0);
return (
<section {...stylex.props(styles.card)}>
<h2 {...stylex.props(styles.heading)}>
Upstreams
<span {...stylex.props(styles.count, shared.tabularNums)}>
{health.available}/{health.total} available
</span>
</h2>
{health.upstreams.length === 0 ? (
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
) : (
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.groupRow)}>
<td colSpan={2} />
<th scope="colgroup" colSpan={3} {...stylex.props(styles.groupHead)}>
Selected period · {health.period}
</th>
</tr>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.th)}>
Upstream
</th>
<th scope="col" {...stylex.props(styles.th)}>
Status now
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Attempts
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Failures
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Success rate
</th>
</tr>
</thead>
<tbody>
{health.upstreams.map((upstream) => {
const status = statusNow(upstream);
return (
<tr key={upstream.url} {...stylex.props(styles.row)}>
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>
{upstream.url}
</td>
<td {...stylex.props(styles.cell)}>
<span
{...stylex.props(
status === "Backing off" && styles.bad,
status === "Disabled" && styles.muted,
)}
>
{status}
</span>
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.attempts)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.failures)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{successRate(upstream.period)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{idle && <p {...stylex.props(styles.note)}>No upstream attempts in this period.</p>}
{!health.complete && (
<p {...stylex.props(styles.note)}>
History incomplete: outcomes were dropped in this window, so these counts are a lower bound.
</p>
)}
</section>
);
}
@@ -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,374 @@
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");
});
test("an absolute window reaches both requests and is stated on the page", async () => {
const bounded = "since=1699999700&until=1700000300";
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
renderRoute(`/diagnostics?${bounded}`);
await screen.findByRole("heading", { name: "Active" });
expect(requested).toContain(`/api/diagnostics?${bounded}&state=active`);
expect(requested).toContain(`/api/diagnostics?${bounded}&state=resolved`);
// An empty section inside a five-minute window means something different
// from an empty section over the whole history, so the page has to say so.
expect(screen.getByText(/Showing events that overlap/)).toBeTruthy();
});
test("clearing the range drops both bounds from the url", async () => {
const bounded = "since=1699999700&until=1700000300";
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
const router = renderRoute(`/diagnostics?${bounded}`);
fireEvent.click(await screen.findByRole("button", { name: "Clear the time range" }));
await waitFor(() => {
expect(router.state.location.search).not.toContain("since");
});
expect(router.state.location.search).not.toContain("until");
});
test("a bound that is not a whole second is dropped, leaving the page unbounded", async () => {
renderRoute("/diagnostics?since=1.5&until=Infinity");
await screen.findByRole("heading", { name: "Active" });
expect(requested).toContain("/api/diagnostics?state=active");
expect(screen.queryByText(/Showing events that overlap/)).toBeNull();
});
@@ -0,0 +1,533 @@
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, 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 { diagnosticsFilterOf } from "./filter";
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,
},
rangeNotice: {
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceHover,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textSecondary,
},
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>
)}
</>
);
}
/**
* The window the page is bounded to, whenever it is bounded.
*
* A link from a query detail arrives with an absolute five-minute window, and
* an empty Active section inside it means something very different from an
* empty Active section over the whole history. The page has to say which it is
* showing, and offer the way out of it.
*/
function RangeNotice({ since, until }: { since?: number; until?: number }) {
const navigate = useNavigate({ from: "/diagnostics" });
if (since === undefined && until === undefined) return null;
const from = since === undefined ? "the start of the history" : formatTime(since);
const to = until === undefined ? "now" : formatTime(until);
return (
<p role="status" {...stylex.props(styles.rangeNotice)}>
Showing events that overlap {from} to {to}.{" "}
<button
type="button"
onClick={() => void navigate({ search: (prev) => ({ ...prev, since: undefined, until: undefined }) })}
{...stylex.props(shared.linkButton, shared.focusRing)}
>
Clear the time range
</button>
</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 = diagnosticsFilterOf(search);
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>
<RangeNotice since={search.since} until={search.until} />
<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,
},
};
+28
View File
@@ -0,0 +1,28 @@
/**
* The Diagnostics search parameters and the API filter they build.
*
* The route, the page and the two infinite queries all have to agree on what
* the URL asked for the page renders the same window the loader prefetched
* so the projection lives in one place rather than being spelled out at each.
*/
import type { DiagnosticSeverity, DiagnosticState, DiagnosticsFilter } from "@/lib/types";
export interface DiagnosticsSearch {
state?: DiagnosticState;
severity?: DiagnosticSeverity;
component?: string;
/** Unix seconds, inclusive. An episode qualifies when its interval overlaps. */
since?: number;
until?: number;
}
/** Field by field, so an unset filter is an absent key rather than `undefined`. */
export function diagnosticsFilterOf(search: DiagnosticsSearch): DiagnosticsFilter {
const filter: DiagnosticsFilter = {};
if (search.severity !== undefined) filter.severity = search.severity;
if (search.component !== undefined) filter.component = search.component;
if (search.since !== undefined) filter.since = search.since;
if (search.until !== undefined) filter.until = search.until;
return filter;
}
@@ -1,25 +1,58 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
groupId: number;
blocklists: Blocklist[];
}
const styles = stylex.create({
note: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
root: {
marginTop: "0.75rem",
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
buttonRow: {
marginTop: "0.75rem",
display: "flex",
gap: "0.5rem",
},
});
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const queryClient = useQueryClient();
const sources = useQuery(groupSourcesQuery(groupId));
const mutation = useMutation(groupSourcesPutMutation(queryClient));
const [selected, setSelected] = useState<number[] | null>(null);
const readOnly = useReadOnlyConfig();
if (sources.isPending) {
return (
<p role="status" className="mt-3 text-sm text-zinc-500">
<p role="status" {...stylex.props(styles.note)}>
Loading sources
</p>
);
@@ -27,27 +60,23 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isError) return <InlineError error={sources.error} />;
if (blocklists.length === 0) {
return (
<p className="mt-3 text-sm text-zinc-500">
No blocklist sources exist yet add them on the Blocklists page.
</p>
);
return <p {...stylex.props(styles.note)}>No blocklist sources exist yet add them on the Blocklists page.</p>;
}
const current = selected ?? sources.data;
const dirty = !sameSet(current, sources.data);
return (
<div className="mt-3">
<ul className="space-y-1">
<div {...stylex.props(styles.root)}>
<ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<label className="inline-flex items-center gap-2 text-sm">
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
{blocklist.name}
</label>
@@ -55,19 +84,24 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
))}
</ul>
<InlineError error={mutation.error} />
<div className="mt-3 flex gap-2">
<div {...stylex.props(styles.buttonRow)}>
<button
type="button"
disabled={!dirty || mutation.isPending}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save sources
</button>
{dirty && (
<button type="button" onClick={() => setSelected(null)} className={buttonClass}>
<button
type="button"
onClick={() => setSelected(null)}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard
</button>
)}
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
blocklistsQuery,
groupCreateMutation,
@@ -10,12 +11,85 @@ import {
import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`;
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
createForm: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
fieldLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
list: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
row: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
rowControls: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
renameForm: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
name: {
fontWeight: 500,
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
actions: {
marginLeft: "auto",
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
groupButton: {
opacity: { default: 1, ":disabled": 0.5 },
},
destructive: {
color: colors.danger,
},
lockNote: {
marginTop: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
export default function GroupsPage() {
const { data: groups } = useSuspenseQuery(groupsQuery());
@@ -23,12 +97,13 @@ export default function GroupsPage() {
const queryClient = useQueryClient();
const createMutation = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
const readOnly = useReadOnlyConfig();
return (
<section>
<h1 className="text-2xl font-semibold">Groups</h1>
<h1 {...stylex.props(styles.heading)}>Groups</h1>
<form
className="mt-4 flex flex-wrap items-center gap-2"
{...stylex.props(styles.createForm)}
onSubmit={(event) => {
event.preventDefault();
const name = newName.trim();
@@ -36,7 +111,7 @@ export default function GroupsPage() {
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
}}
>
<label className="text-sm font-medium" htmlFor="new-group-name">
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
New group
</label>
<input
@@ -44,14 +119,20 @@ export default function GroupsPage() {
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
className={smallInputClass}
disabled={readOnly}
{...stylex.props(shared.smallInput, shared.focusRing)}
/>
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
<button
type="submit"
disabled={createMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Create
</button>
</form>
<InlineError error={createMutation.error} />
<ul className="mt-6 space-y-4">
<ul {...stylex.props(styles.list)}>
{groups.map((group) => (
<GroupRow key={group.id} group={group} blocklists={blocklists} />
))}
@@ -69,13 +150,15 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
const [confirming, setConfirming] = useState(false);
const [expanded, setExpanded] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID;
const readOnly = useReadOnlyConfig();
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
return (
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
<div className="flex flex-wrap items-center gap-3">
<li {...stylex.props(styles.row)}>
<div {...stylex.props(styles.rowControls)}>
{renaming ? (
<form
className="flex items-center gap-2"
{...stylex.props(styles.renameForm)}
onSubmit={(event) => {
event.preventDefault();
const trimmed = name.trim();
@@ -91,10 +174,15 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
className={smallInputClass}
{...stylex.props(shared.smallInput, shared.focusRing)}
autoFocus
/>
<button type="submit" disabled={updateMutation.isPending} className={groupButtonClass}>
<button
type="submit"
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Save
</button>
<button
@@ -103,20 +191,21 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name);
setRenaming(false);
}}
className={groupButtonClass}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel
</button>
</form>
) : (
<span className="font-medium">{group.name}</span>
<span {...stylex.props(styles.name)}>{group.name}</span>
)}
<label className="inline-flex items-center gap-2 text-sm">
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={group.safe_search}
disabled={updateMutation.isPending}
className={focusRing}
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.focusRing)}
onChange={(event) =>
updateMutation.mutate({
id: group.id,
@@ -126,25 +215,25 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
/>
Safe search
</label>
<span className="ml-auto inline-flex flex-wrap items-center gap-2">
<span {...stylex.props(styles.actions)}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
className={groupButtonClass}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Sources
</button>
{!renaming && (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => {
setName(group.name);
setRenaming(true);
}}
className={groupButtonClass}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Rename
</button>
@@ -157,28 +246,42 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setConfirming(false);
deleteMutation.mutate(group.id);
}}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
>
Confirm delete
</button>
<button type="button" onClick={() => setConfirming(false)} className={groupButtonClass}>
<button
type="button"
onClick={() => setConfirming(false)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel
</button>
</>
) : (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => setConfirming(true)}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
>
Delete
</button>
)}
</span>
</div>
{isDefault && <p className="mt-2 text-xs text-zinc-500">{DEFAULT_GROUP_NOTE}</p>}
{isDefault && <p {...stylex.props(styles.lockNote)}>{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={updateMutation.error ?? deleteMutation.error} />
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
</li>
@@ -0,0 +1,170 @@
import { fireEvent, render, screen, waitFor } 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 { LocalRecord, LocalRecordInput } from "@/lib/types";
let records: LocalRecord[];
let fetchMock: ReturnType<typeof createFetchMock>;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
function createFetchMock() {
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
if (url === "/api/local-records" && method === "GET") return json({ local_records: records });
if (url === "/api/local-records" && method === "POST") {
const body = JSON.parse(String(init?.body)) as LocalRecordInput;
const created: LocalRecord = { id: 99, ttl: body.ttl ?? 300, ...body };
records = [...records, created];
return json(created, 201);
}
if (url.startsWith("/api/local-records/") && method === "DELETE") {
const id = Number(url.slice("/api/local-records/".length));
records = records.filter((record) => record.id !== id);
return new Response(null, { status: 204 });
}
if (url.startsWith("/api/forward-zones/") && method === "DELETE") return new Response(null, { status: 204 });
if (url === "/api/forward-zones" && method === "GET") {
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
}
return json({ error: "not stubbed" }, 404);
});
}
beforeEach(() => {
records = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.10", ttl: 300 }];
fetchMock = createFetchMock();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderPage() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/local-dns"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
}
test("renders the records table and switches to the forward zones tab", async () => {
renderPage();
await screen.findByRole("heading", { name: "Local DNS" });
await screen.findByText("nas.lan.home");
expect(screen.getByText("192.168.1.10")).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
});
test("the arrow keys move between tabs", async () => {
renderPage();
await screen.findByText("nas.lan.home");
const tablist = screen.getByRole("tablist", { name: "Local DNS" });
const records = screen.getByRole("tab", { name: "Records" });
expect(records.getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
const zones = screen.getByRole("tab", { name: "Forward zones" });
expect(zones.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("false");
await screen.findByText("lan.home");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true");
await screen.findByText("nas.lan.home");
});
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
// The record type is a RAC Select now: open the listbox, then pick.
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("printer.lan.home");
const post = fetchMock.mock.calls.find(
([input, init]) => init?.method === "POST" && String(input) === "/api/local-records",
);
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
});
test("cancelling the record delete dialog sends no request", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
expect(screen.getByText("nas.lan.home")).toBeTruthy();
});
test("confirming the record delete dialog issues the DELETE", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/local-records/1",
),
).toBe(true),
);
await waitFor(() => expect(screen.queryByText("nas.lan.home")).toBeNull());
});
test("the forward zone delete dialog names the zone and confirms", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/forward-zones/7",
),
).toBe(true),
);
});
+27
View File
@@ -0,0 +1,27 @@
import * as stylex from "@stylexjs/stylex";
import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab";
import Tabs from "@/ui/Tabs";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
});
export default function LocalDnsPage() {
return (
<section>
<h1 {...stylex.props(styles.heading)}>Local DNS</h1>
<Tabs
label="Local DNS"
tabs={[
{ id: "records", label: "Records", content: <RecordsTab /> },
{ id: "zones", label: "Forward zones", content: <ZonesTab /> },
]}
/>
</section>
);
}
+334
View File
@@ -0,0 +1,334 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
localRecordCreateMutation,
localRecordDeleteMutation,
localRecordUpdateMutation,
localRecordsQuery,
} from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
function RecordForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: LocalRecord;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: LocalRecordInput) => void;
onCancel: () => void;
}) {
const id = useId();
const [name, setName] = useState(initial?.name ?? "");
const [rtype, setRtype] = useState<LocalRecordType>(initial?.rtype ?? "A");
const [value, setValue] = useState(initial?.value ?? "");
const [ttl, setTtl] = useState(initial === undefined ? "" : String(initial.ttl));
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const input: LocalRecordInput = { name: name.trim(), rtype, value: value.trim() };
if (ttl.trim() !== "") input.ttl = Number(ttl);
onSubmit(input);
}
return (
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New record" : `Edit ${initial.name}`}
</h3>
<div>
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
Name
</label>
<input
id={`${id}-name`}
required
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="nas.lan.home"
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<Select
label="Type"
value={rtype}
onChange={(next) => setRtype(next as LocalRecordType)}
options={RTYPE_OPTIONS}
/>
</div>
<div>
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
Value
</label>
<input
id={`${id}-value`}
required
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
TTL (seconds)
</label>
<input
id={`${id}-ttl`}
type="number"
min={0}
value={ttl}
onChange={(event) => setTtl(event.target.value)}
placeholder="300"
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel
</button>
</div>
<InlineError error={error} />
</form>
);
}
export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data;
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<LocalRecord, LocalRecordInput>({
create: localRecordCreateMutation,
update: localRecordUpdateMutation,
remove: localRecordDeleteMutation,
confirmDelete: (record) => `Delete record "${record.name}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add record
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<RecordForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
)}
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Name
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Type
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Value
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
TTL
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
No local records yet.
</td>
</tr>
)}
{records.map((record) => (
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}>
<RecordForm
initial={record}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(record)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
forwardZoneCreateMutation,
forwardZoneDeleteMutation,
forwardZoneUpdateMutation,
forwardZonesQuery,
} from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
function ZoneForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: ForwardZone;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: ForwardZoneInput) => void;
onCancel: () => void;
}) {
const id = useId();
const [zone, setZone] = useState(initial?.zone ?? "");
const [resolver, setResolver] = useState(initial?.resolver ?? "");
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
onSubmit({ zone: zone.trim(), resolver: resolver.trim() });
}
return (
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
</h3>
<div>
<label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
Zone
</label>
<input
id={`${id}-zone`}
required
value={zone}
onChange={(event) => setZone(event.target.value)}
placeholder="lan.home"
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
Resolver
</label>
<input
id={`${id}-resolver`}
required
value={resolver}
onChange={(event) => setResolver(event.target.value)}
placeholder="udp://192.168.1.1:53"
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel
</button>
</div>
<InlineError error={error} />
</form>
);
}
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<ForwardZone, ForwardZoneInput>({
create: forwardZoneCreateMutation,
update: forwardZoneUpdateMutation,
remove: forwardZoneDeleteMutation,
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>
Names under these zones go to their own resolver. Changes apply live.
</p>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add zone
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<ZoneForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
)}
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Zone
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
Resolver
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{zones.length === 0 && (
<tr>
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
No forward zones yet.
</td>
</tr>
)}
{zones.map((zone) => (
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
initial={zone}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: zone })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(zone)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete forward zone"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { buttonClass, insetFocusRing } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DURATIONS = [
{ label: "60 seconds", seconds: 60 },
@@ -11,6 +13,72 @@ const DURATIONS = [
{ label: "Indefinitely", seconds: null },
] as const;
const styles = stylex.create({
/**
* Disabled text darkens in light scheme and lightens in dark, the opposite
* direction from `textMuted`, so the token cannot express it.
*/
trigger: {
color: {
default: null,
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
},
pausedRow: {
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
},
pausedControls: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
/**
* Amber as standalone text on the app ground, not inside a warning banner, so
* the `warn*` tokens tuned against `warnSurface` do not apply here.
*/
pausedLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(55.5% 0.163 48.998)",
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
},
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
right: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export function formatRemaining(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds);
const hours = Math.floor(clamped / 3600);
@@ -35,8 +103,6 @@ function useNowSeconds(active: boolean): number {
return now;
}
const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`;
export default function PauseWidget() {
const queryClient = useQueryClient();
const { data } = useQuery({
@@ -52,7 +118,7 @@ export default function PauseWidget() {
if (data === undefined) {
return (
<button type="button" disabled className={triggerButtonClass}>
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
Pause
</button>
);
@@ -60,16 +126,16 @@ export default function PauseWidget() {
if (data.paused) {
return (
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-amber-700 dark:text-amber-400">
<div {...stylex.props(styles.pausedRow)}>
<div {...stylex.props(styles.pausedControls)}>
<span {...stylex.props(styles.pausedLabel)}>
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
</span>
<button
type="button"
onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending}
className={triggerButtonClass}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Resume
</button>
@@ -81,7 +147,7 @@ export default function PauseWidget() {
return (
<div
className="relative"
{...stylex.props(styles.anchor)}
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
@@ -92,15 +158,12 @@ export default function PauseWidget() {
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
className={triggerButtonClass}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Pause
</button>
{menuOpen && (
<div
id="pause-menu"
className="absolute right-0 top-full z-10 mt-1 flex w-36 flex-col rounded border border-zinc-200 bg-white py-1 shadow dark:border-zinc-800 dark:bg-zinc-900"
>
<div id="pause-menu" {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<button
key={label}
@@ -111,7 +174,7 @@ export default function PauseWidget() {
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
>
{label}
</button>
@@ -0,0 +1,39 @@
import {
ENUM_VALUES,
policyActionLabel,
policyReasonLabel,
qclassName,
rcodeName,
routeKindLabel,
} from "./provenanceCopy";
/**
* `tsc` proves the maps total over the union; this proves the union is the set
* the server actually stores, and that no entry was left as its raw tag name.
*/
test("every stored enum value has a label of its own", () => {
const labels = [
...ENUM_VALUES.policyAction.map(policyActionLabel),
...ENUM_VALUES.policyReason.map(policyReasonLabel),
...ENUM_VALUES.routeKind.map(routeKindLabel),
];
for (const label of labels) {
expect(label).not.toBe("");
expect(label).not.toMatch(/_/);
}
expect(new Set(ENUM_VALUES.policyReason.map(policyReasonLabel)).size).toBe(ENUM_VALUES.policyReason.length);
});
test("response codes read by name where one exists, by number where none does", () => {
expect(rcodeName(0)).toBe("NOERROR (0)");
expect(rcodeName(3)).toBe("NXDOMAIN (3)");
expect(rcodeName(16)).toBe("BADVERS (16)");
// The column holds the twelve-bit extended code, most of which is unassigned.
expect(rcodeName(3841)).toBe("RCODE 3841");
});
test("query classes read the same way", () => {
expect(qclassName(1)).toBe("IN (1)");
expect(qclassName(255)).toBe("ANY (255)");
expect(qclassName(42)).toBe("CLASS 42");
});
@@ -0,0 +1,103 @@
import { POLICY_ACTIONS, POLICY_REASONS, ROUTE_KINDS } from "@/lib/types";
import type { PolicyAction, PolicyReason, RouteKind } from "@/lib/types";
/**
* Display names for the three stored enums. `Record` over the union, so a value
* added to `src/storage/provenance.zig` and mirrored into `lib/types.ts` fails
* `tsc` here instead of reaching a cell as a raw tag name.
*/
const POLICY_ACTION_LABELS: Record<PolicyAction, string> = {
not_evaluated: "Not evaluated",
allow: "Allowed",
block: "Blocked",
};
const POLICY_REASON_LABELS: Record<PolicyReason, string> = {
rule_allow_exact: "Allow rule (exact)",
rule_block_exact: "Block rule (exact)",
rule_allow_wildcard: "Allow rule (wildcard)",
rule_block_wildcard: "Block rule (wildcard)",
rule_allow_regex: "Allow rule (regex)",
rule_block_regex: "Block rule (regex)",
blocklist_exception: "Blocklist exception",
blocklist_domain: "Blocklist (domain)",
blocklist_wildcard: "Blocklist (wildcard)",
local_record: "Local record",
forward_zone: "Forward zone",
non_in_class: "Not class IN",
paused: "Filtering paused",
snapshot_unavailable: "No filter snapshot",
no_match: "No match",
protocol_error: "Protocol refusal",
};
const ROUTE_KIND_LABELS: Record<RouteKind, string> = {
blocked: "Blocked locally",
local: "Local record",
forward_zone: "Forward zone",
upstream: "Upstream resolver",
cache: "Cache",
rejected: "Rejected",
};
export function policyActionLabel(action: PolicyAction): string {
return POLICY_ACTION_LABELS[action];
}
export function policyReasonLabel(reason: PolicyReason): string {
return POLICY_REASON_LABELS[reason];
}
export function routeKindLabel(kind: RouteKind): string {
return ROUTE_KIND_LABELS[kind];
}
/** The enum value sets, for tests that prove the maps exhaustive at runtime too. */
export const ENUM_VALUES = {
policyAction: POLICY_ACTIONS,
policyReason: POLICY_REASONS,
routeKind: ROUTE_KINDS,
} as const;
const RCODE_NAMES: Record<number, string> = {
0: "NOERROR",
1: "FORMERR",
2: "SERVFAIL",
3: "NXDOMAIN",
4: "NOTIMP",
5: "REFUSED",
6: "YXDOMAIN",
7: "YXRRSET",
8: "NXRRSET",
9: "NOTAUTH",
10: "NOTZONE",
16: "BADVERS",
};
/**
* The bare mnemonic, for a table cell with no room for the number. An
* unassigned code has no mnemonic to shorten, so it keeps the same `RCODE <n>`
* shape the long form falls back to.
*/
export function rcodeShortName(rcode: number): string {
return RCODE_NAMES[rcode] ?? `RCODE ${rcode}`;
}
/** The twelve-bit extended code as `NXDOMAIN (3)`; an unassigned code keeps its number. */
export function rcodeName(rcode: number): string {
const name = RCODE_NAMES[rcode];
return name === undefined ? `RCODE ${rcode}` : `${name} (${rcode})`;
}
const QCLASS_NAMES: Record<number, string> = {
1: "IN",
3: "CH",
4: "HS",
254: "NONE",
255: "ANY",
};
export function qclassName(qclass: number): string {
const name = QCLASS_NAMES[qclass];
return name === undefined ? `CLASS ${qclass}` : `${name} (${qclass})`;
}
@@ -0,0 +1,58 @@
import type { Provenance, QueryRow } from "@/lib/types";
/**
* Fixture builders for the provenance shapes, shared by the query-log, detail
* and live-stream tests the way `features/activity/fakeEventSource.ts` is shared.
*
* The defaults describe the dullest possible query an allowed name nothing
* matched, answered upstream so each test states only the fields it is about.
*/
type Sections = {
[K in keyof Provenance]?: Partial<Provenance[K]>;
};
export function provenance(sections: Sections = {}): Provenance {
return {
request: {
time: 1_700_000_000,
domain: "example.com",
client: "192.0.2.10",
qtype: 1,
qclass: 1,
...sections.request,
},
group: { id: 1, name: "default", ...sections.group },
policy: {
action: "allow",
reason: "no_match",
matched: "",
source_id: null,
source_name: "",
...sections.policy,
},
rewrites: { cname_target: "", safe_search_target: "", ...sections.rewrites },
route: { kind: "upstream", forward_zone: "", upstream: "https://dns.example/dns-query", ...sections.route },
response: { rcode: 0, duration_us: 1234, ...sections.response },
};
}
/** The flat stored row of the same dull query. */
export function queryRow(id: number, overrides: Partial<QueryRow> = {}): QueryRow {
return {
id,
ts: 1_700_000_000,
domain: "example.com",
client_ip: "192.0.2.10",
qtype: 1,
qclass: 1,
rcode: 0,
blocked: false,
response_time_us: 1234,
cache_hit: false,
upstream: "https://dns.example/dns-query",
policy_action: "allow",
policy_reason: "no_match",
route_kind: "upstream",
...overrides,
};
}
@@ -0,0 +1,88 @@
import type { LiveQueryEvent, PolicyReason, QueryRow, RouteKind } from "@/lib/types";
/**
* What the query-log table renders for one row, whichever surface it came from.
*
* The stored list row and the live stream's provenance event describe the same
* query in two different shapes flat summary against nested full detail and
* both pages share one set of cells, so both project into this.
*
* `id` is null for a streamed event: the frame precedes its own insert, so no
* row exists to link to yet.
*/
export interface QuerySummary {
id: number | null;
ts: number;
domain: string;
client_ip: string;
qtype: number | null;
blocked: boolean;
policy_reason: PolicyReason;
/** The twelve-bit extended code the client saw, including a synthesized SERVFAIL. */
rcode: number;
route_kind: RouteKind;
response_time_us: number | null;
cache_hit: boolean | null;
upstream: string;
}
/**
* Whether the cache answered, or null where it never applied. Mirrors
* `Context.cacheHit` in src/server/handler.zig, which derives the stored
* `cache_hit` column from the same route: a local record, a blocked answer and
* a protocol refusal all bypass the cache, and "miss" would claim a lookup that
* never happened.
*/
export function cacheHitFor(kind: RouteKind): boolean | null {
switch (kind) {
case "cache":
return true;
case "upstream":
case "forward_zone":
return false;
case "local":
case "blocked":
case "rejected":
return null;
}
}
export function summarizeRow(row: QueryRow): QuerySummary {
return {
id: row.id,
ts: row.ts,
domain: row.domain,
client_ip: row.client_ip,
qtype: row.qtype,
blocked: row.blocked,
policy_reason: row.policy_reason,
rcode: row.rcode,
route_kind: row.route_kind,
response_time_us: row.response_time_us,
cache_hit: row.cache_hit,
upstream: row.upstream,
};
}
/**
* The same summary out of a live frame. `blocked` and `cache_hit` are derived
* rather than sent: the server derives the stored columns from exactly these
* two fields (handler.zig's `Entry.init` call), so the projection reproduces
* them instead of the DTO carrying the same fact twice.
*/
export function summarizeEvent(event: LiveQueryEvent): QuerySummary {
return {
id: null,
ts: event.request.time,
domain: event.request.domain,
client_ip: event.request.client,
qtype: event.request.qtype,
blocked: event.policy.action === "block",
policy_reason: event.policy.reason,
rcode: event.response.rcode,
route_kind: event.route.kind,
response_time_us: event.response.duration_us,
cache_hit: cacheHitFor(event.route.kind),
upstream: event.route.upstream,
};
}
+233
View File
@@ -0,0 +1,233 @@
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";
const RESPONSES: Record<string, unknown> = {
"/api/rules": {
rules: [
{
id: 1,
group_id: 1,
group: "Default",
pattern: "ads.example.com",
kind: "exact",
action: "block",
created_at: 1700000000,
},
{
id: 2,
group_id: 2,
group: "Kids",
pattern: "*.cdn.example.com",
kind: "wildcard",
action: "allow",
created_at: 1700000100,
},
],
},
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
// The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[];
let deleted: string[];
let posted: { pattern: string; kind: string }[];
function deleteCalls(): string[] {
return deleted;
}
beforeEach(() => {
groups = [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
];
deleted = [];
posted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/rules" && init?.method === "POST") {
posted.push(JSON.parse(String(init.body)) as { pattern: string; kind: string });
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "5" },
});
}
const payload = url === "/api/groups" ? { groups } : RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderRulesRoute() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/rules"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
}
/**
* A RAC Select names its trigger with the current value and then the label, so
* the label alone is a suffix match. Opening it is the only way to read the
* options: there is no `<select>` carrying them any more.
*/
function trigger(label: string): HTMLElement {
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}
async function optionsOf(label: string): Promise<(string | null)[]> {
fireEvent.click(trigger(label));
const options = await screen.findAllByRole("option");
const labels = options.map((option) => option.textContent);
// Re-picking the current value closes the listbox and changes nothing.
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
return labels;
}
test("renders the rule table and the create form with contract enums", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const table = within(screen.getByRole("table"));
expect(table.getByText("ads.example.com")).toBeTruthy();
expect(table.getByText("*.cdn.example.com")).toBeTruthy();
expect(table.getByText("block")).toBeTruthy();
expect(table.getByText("allow")).toBeTruthy();
expect(table.getByText("Kids")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard", "regex"]);
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
});
test("the kind selector can select the regex option, not only list it", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(trigger("Kind"));
const options = await screen.findAllByRole("option");
const regex = options.find((option) => option.textContent === "regex");
expect(regex).toBeTruthy();
fireEvent.click(regex!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
expect(trigger("Kind").textContent).toContain("regex");
});
async function selectKind(label: string): Promise<void> {
fireEvent.click(trigger("Kind"));
const options = await screen.findAllByRole("option");
fireEvent.click(options.find((option) => option.textContent === label)!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
}
// A regex is stored and matched byte for byte, so whitespace inside it is data,
// not slop the UI may drop. Exact and wildcard are normalized server-side.
test("a regex pattern is posted untrimmed, an exact pattern is trimmed", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
await selectKind("regex");
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " foo|bar " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(posted).toHaveLength(1));
expect(posted[0]).toMatchObject({ pattern: " foo|bar ", kind: "regex" });
await selectKind("exact");
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(posted).toHaveLength(2));
expect(posted[1]).toMatchObject({ pattern: "ads.example.net", kind: "exact" });
});
test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const input = screen.getByLabelText("Pattern");
expect(input.getAttribute("autocapitalize")).toBe("none");
expect(input.getAttribute("autocorrect")).toBe("off");
expect(input.getAttribute("spellcheck")).toBe("false");
});
test("rule create shows a countdown when rate limited with Retry-After", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 5s.");
});
test("the group select preselects the id-1 default, not the alphabetically first group", async () => {
groups = [
{ id: 5, name: "Attic", safe_search: false },
{ id: 1, name: "Default", safe_search: false },
];
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
expect(trigger("Group").textContent).toContain("Default");
});
test("the group select falls back to the first group when the default is absent", async () => {
groups = [
{ id: 5, name: "Attic", safe_search: false },
{ id: 7, name: "Basement", safe_search: false },
];
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect(trigger("Group").textContent).toContain("Attic");
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleteCalls()).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that rule", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
});
+236
View File
@@ -0,0 +1,236 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const KIND_OPTIONS = [
{ value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" },
{ value: "regex", label: "regex" },
];
const ACTION_OPTIONS = [
{ value: "allow", label: "allow" },
{ value: "block", label: "block" },
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
pattern: {
fontWeight: 500,
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1.5rem",
maxWidth: "36rem",
},
formHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
/** One column on a phone, three from the `sm` breakpoint, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
},
},
submitRow: {
display: "flex",
},
});
export default function RulesPage() {
const queryClient = useQueryClient();
const { data: rules } = useSuspenseQuery(rulesQuery());
const { data: groups } = useSuspenseQuery(groupsQuery());
const create = useMutation(ruleCreateMutation(queryClient));
const remove = useMutation(ruleDeleteMutation(queryClient));
const [pattern, setPattern] = useState("");
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// A regex pattern is stored and matched byte for byte, so the UI must not
// edit it: trimming here would make a UI-created rule differ from the same
// bytes posted to /api/rules. Name-shaped kinds are normalized server-side,
// so trimming them only spares a pasted space a 400.
const sent = kind === "regex" ? pattern : pattern.trim();
create.mutate({ group_id: groupId, pattern: sent, kind, action }, { onSuccess: () => setPattern("") });
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
return (
<section>
<h1 {...stylex.props(styles.heading)}>Rules</h1>
{rules.length === 0 ? (
<p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Group</th>
<th {...stylex.props(shared.th)}>Created</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
{rule.action}
</span>
</td>
<td {...stylex.props(shared.td)}>{rule.group}</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td {...stylex.props(shared.td)}>
<button
type="button"
onClick={() => setPendingDelete(rule)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<InlineError error={remove.error} />
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
<div>
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
Pattern
</label>
<input
id="rule-pattern"
type="text"
required
value={pattern}
onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com, *.example.com or ^ad[0-9]+-"
// A phone keyboard capitalizing the first letter is silent for
// exact and wildcard (normalized server-side) but fatal for a
// regex, which matches the lowercase query name byte for byte.
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.fieldGrid)}>
<Select
label="Kind"
value={kind}
onChange={(value) => setKind(value as RuleKind)}
options={KIND_OPTIONS}
/>
<Select
label="Action"
value={action}
onChange={(value) => setAction(value as RuleAction)}
options={ACTION_OPTIONS}
/>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
</div>
<div {...stylex.props(styles.submitRow)}>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
</div>
<InlineError error={create.error} />
</form>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}

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