35 Commits
Author SHA1 Message Date
mokhtar 039eeeda96 admin: activity history filters become a live-apply toolbar
Gates / frontend (push) Failing after 1m4s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test (push) Successful in 2m33s
Gates / test-aarch64 (push) Successful in 7m33s
CI / gates (push) Failing after 10m6s
the five-field form with its apply button is gone. one row holds a search-shaped domain field and a client ip field that debounce into the url with enter flushing at once, result segments and a time menu that commit instantly, and a clear that appears only when a filter is active without reflowing the row. presets freeze both absolute bounds at click time so a bookmark describes the same investigation later, custom ranges apply atomically through set range with inline validation, and echo queues keep in-flight commits from clobbering newer typing or newer picks. live mode renders no toolbar, the availability banner became a footer note, previous rows stay visible during refetch, and every control carries a 44px hit target.
2026-08-29 14:56:40 +02:00
mokhtar c65d92d8f8 admin: title-only information becomes visible text
the client address follows its name as visible muted text in the query tables, the config lock indicator prints its reason beside the tag except in table rows where a page-level note explains the lock instead, and the locked delete buttons describe themselves through that one visible note. the chart legend tooltip is deleted because a named client is deliberately not addressed in the chart, and the dead series address field went with it. titles that merely repeat visible copyable text stay.
2026-08-29 13:03:30 +02:00
mokhtar 207252acee admin: enable toggles become rac switches
the upstream, blocklist source, and safe search enable controls mutate the row the moment they move, so they now carry the switch role via a shared drawn rac switch with a 44px hit area, a focus-visible ring, and naming modes made exclusive by a discriminated union. safe search supersedes its one-commit-old checkbox form, and the file-authority guard now names the switch role instead of relying on the bare input selector.
2026-08-29 12:40:01 +02:00
mokhtar f1de80477a admin: group sources and safe search become rac checkboxes
the assigned sources list is a rac checkboxgroup and safe search uses the same drawn checkbox, extracted to a shared ui component with grouped and standalone modes enforced by a discriminated union. the label carries a 44px pointer-target floor on both axes, the focus ring is driven from rac's focus-visible state and guarded by a test, and toggleSource is gone because the group hands back the whole set.
2026-08-29 12:33:00 +02:00
mokhtar 3c674966be admin: the mobile drawer becomes a rac disclosure, asset budget raised to 850,000 bytes
the drawer is inline flow content, so disclosure is the honest semantic: rac now owns aria-expanded, aria-controls, and the panel hidden state, while the open guard still unmounts the drawer contents so a closed drawer keeps no second pause control or health poll alive. the disclosure modules cost 3,669 bytes and the assets gate had 1,998 of headroom, so the budget moves from 800,000 to 850,000.
2026-08-29 12:20:07 +02:00
mokhtar 59d6be98f8 admin: associate inline field errors with their inputs
network assignment rows, activity filters, and the settings password pair now mark the offending input with aria-invalid and point it at the error text with aria-describedby. the prefix validator returns the row and field it is about, and any row mutation clears a message that named a position. settings numeric fields carry aria-invalid on an unparseable value.
2026-08-29 12:08:19 +02:00
mokhtar 317d5dd4f8 admin: live query detail becomes a modal, live/history and period pickers become tabs and radios, client delete confirms in a dialog
the streamed-query detail panel is now a rac modal dialog with focus containment and restore. the live/history switch is rac tabs driven by the url, the overview period picker is a rac radio group, and the clients delete flow uses the shared confirm dialog; an authority turn keeps the dialog open and withdraws only the destructive action.
2026-08-29 11:55:24 +02:00
mokhtar 79578e1d2a admin: reclaim the desktop header, move pause and log out to the sidebar
the header row survives only on narrow screens; on desktop its lone occupant, log out, joins pause in the sidebar footer, both full width. pause leaves the query detail page's related actions, where a global control had no business, and its hand-rolled duration dropdown becomes a react-aria menu with real keyboard navigation, dismissal and positioning.
2026-08-29 11:23:17 +02:00
mokhtar d5613ee718 admin: pointer cursor on every button
the shared button variants set no cursor, so only components with local one-off styles showed pointer. all nine variants and the six buttons styled outside them now carry pointer, with not-allowed when disabled.
2026-08-29 01:29:11 +02:00
mokhtar 09932b2d84 build: bump version to 0.0.14
Release / guard (push) Successful in 36s
Gates / frontend (push) Successful in 1m46s
Gates / test (push) Successful in 2m30s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 4m27s
Gates / container (push) Successful in 14s
Release / gates (push) Successful in 14m50s
Release / publish (push) Failing after 6m17s
2026-08-28 17:57:09 +02:00
mokhtar 272655f60c storage: version querylog.db and migrate it in place, never reset a healthy file
querylog.db carries a schema version; migrations run at startup as one transaction after a vacuumed 0600 backup, and every failure refuses startup (exit 2, no systemd restart loop) instead of starting empty. corruption is the only automatic recreate left. the cut gate now requires a fixture-proven migration or an explicit versioned break with restore instructions, and locks shipped migration files and fixtures byte-for-byte.
2026-08-28 17:56:19 +02:00
mokhtar dd5a9f6ab8 build: bump version to 0.0.13
Release / guard (push) Canceled after 0s
Release / gates (push) Canceled after 0s
Release / publish (push) Canceled after 0s
2026-08-27 21:46:08 +02:00
mokhtar 72cdbbd113 upstream: one absolute per-query budget across queueing and failover
waiting for a slot now spends the query budget; truncated attempts that
expire fault the budget, not the upstream, and are never attributed.
admission sweeps in priority order before blocking. forward zones spend
read_timeout_ms once across udp, truncation and tcp. adds
nxdns_upstream_budget_exhausted_total and a 64-upstream validation limit.
2026-08-27 21:10:43 +02:00
mokhtar 6170571d23 build: bump version to 0.0.12
Release / guard (push) Canceled after 0s
Release / gates (push) Canceled after 0s
Release / publish (push) Canceled after 0s
2026-08-27 17:49:06 +02:00
mokhtar c8470724ce overview: one endpoint, live projections and a response cache (m36) 2026-08-27 17:48:20 +02:00
mokhtar 7cdb74ec17 build: bump version to 0.0.11
Release / guard (push) Canceled after 0s
Release / gates (push) Canceled after 0s
Release / publish (push) Canceled after 0s
2026-08-24 18:41:46 +02:00
mokhtar 4367003788 changelog: 0.0.11 2026-08-24 18:40:29 +02:00
mokhtar 728b8d64e1 admin: draw the overview charts with visx
the hand-written scale, tick, stacking and arc math is replaced by visx 4.0.0
primitives; rendering, colours and themes stay the app's own. all four charts
share one hover treatment: the client chart gains the tooltip and dimming the
query timeline had, the donuts gain both, an open tooltip follows a data
refresh instead of going stale, and it retires when the window rolls. the
timeline's third series is named allowed instead of other, and the client
chart's other aggregate disappears from a window where it counted nothing.
licenses gain the isc text for the bundled d3 modules.
2026-08-24 18:40:17 +02:00
mokhtar 091962a996 build: bump version to 0.0.10
Release / guard (push) Canceled after 0s
Release / gates (push) Canceled after 0s
Release / publish (push) Canceled after 0s
2026-08-24 00:05:33 +02:00
mokhtar 4fcbd0ac12 changelog: 0.0.10 2026-08-24 00:05:21 +02:00
mokhtar d961b152a3 db-mode config changes apply live in-process
settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
2026-08-24 00:04:28 +02:00
mokhtar 17e6e93ce1 admin: only the content region scrolls on wide screens
the shell grid grew past the viewport and scrolled the document,
carrying the sidebar with it. the shell is now viewport-height at the
wide breakpoint with main as the sole scroll container; the nav list
scrolls inside the pinned rail; router scroll restoration targets the
inner scroller so navigation resets it and back/forward restores it.
2026-08-23 15:17:51 +02:00
mokhtar 8a44d901d9 cut: schema gate — refuse to release an undisclosed querylog schema change
the gate recomputes the previous release tag's ddl fingerprint from the
remote peeled object and compares it against the tree's; a change must
be disclosed by 'resets your query history' in the version's changelog
section. the 0.0.9 reset shipped with an announcement claiming no
schema change; this makes the impact mechanical instead of remembered.
2026-08-23 15:07:27 +02:00
mokhtar 03149b5d65 ci: drop the one-insn-per-tb workaround, the qemu panic is an 11.1.0-only regression
Release / guard (push) Canceled after 0s
Release / gates (push) Canceled after 0s
Release / publish (push) Canceled after 0s
the bisect proved the runner image's qemu 8.2 was never affected; the
hangs this masked were the logger deadlock fixed in f1a85d3. the 10x
slowdown it cost also broke the full-batch flush timing test in run 592.
2026-08-23 13:55:22 +02:00
mokhtar a972c8db4d logger tests: join every writer future before its database closes, fix the gate-open race 2026-08-23 12:26:09 +02:00
mokhtar 78d688a40f ci: run the aarch64 suite with one qemu insn per tb, tcg optimization falsely trips the ubsan pointer check 2026-08-23 12:26:05 +02:00
mokhtar fe5bd967ca admin: live ring capacity is injectable, eviction test no longer timing-bound 2026-08-23 09:04:18 +02:00
mokhtar 1f215ac623 build: bump version to 0.0.9 2026-08-22 23:32:47 +02:00
mokhtar 2f86cd72fd changelog: 0.0.9 releases today 2026-08-22 23:32:36 +02:00
mokhtar 51cda8b8cb milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling 2026-08-22 23:31:37 +02:00
mokhtar e5f23d5466 querylog: return to the default checkpoint cadence 2026-08-22 23:10:17 +02:00
mokhtar 24521ab9a9 milestone 32: task-shaped configuration, file mode as a rendering, config status api 2026-08-22 22:42:50 +02:00
mokhtar c99a37d170 milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics 2026-08-22 19:54:02 +02:00
mokhtar 623667e475 milestone 30: overview as a dashboard, explicit health contract, period aggregations 2026-08-22 16:45:15 +02:00
mokhtar 0e83477d80 agents: move opus-coder to the global scope
The routing rule is global, so the agent it names lives next to it now
(~/.claude/agents/opus-coder.md), at medium effort and without the
milestone-spec wording that only fit this repository.
2026-08-22 10:53:19 +02:00
276 changed files with 36813 additions and 11258 deletions
-8
View File
@@ -1,8 +0,0 @@
---
name: opus-coder
description: Coding sessions routed per ~/.claude/rules/model-routing.md — all substantial and mechanical implementation work runs on Opus 5 at high effort; Fable stays for decomposition and review.
model: opus
effort: high
---
You implement one milestone session from a spec in this repository. The spec you are pointed at is the binding contract. Own only the files your session is assigned; do not commit; verify your work with the repo's build and test commands; report what you changed, what you ran, and every result faithfully, including failures.
+5
View File
@@ -101,6 +101,11 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -qq -y --no-install-recommends qemu-user
# qemu 11.1.0 has a TCG regression: its translation-block optimization
# takes the panic branch of Zig's UBSan pointer-overflow check on a
# valid in-bounds pointer (11.0.3 and earlier are clean; bisected via
# the Arch archive). This image's qemu 8.2 is not affected — do not
# upgrade the emulator past 11.0.x until qemu fixes it.
- name: Run test suite under qemu (plain suite, no -Dintegration)
run: zig build test-aarch64 -fqemu
+17
View File
@@ -32,6 +32,23 @@ Any *other* failure text is real. Trust the summary line: `zig build test` exiti
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.
## Debug-mode miscompile: a `bool` live across an atomic read-modify-write
In Debug the x86_64 self-hosted backend is the default, and zig 0.16.0's atomic read-modify-write lowering there does not invalidate a `bool` the register allocator is still tracking in EFLAGS. The `bool` silently becomes the flags the `lock xadd` left behind. Release modes go through LLVM and are unaffected, so this can only ever break `zig build test`, never a shipped binary.
The shape to avoid is a comparison whose result stays live across `fetchAdd`/`fetchSub`/`@atomicRmw` and is then branched on:
```zig
const idle = old.refs == 0; // sete 0x50(%rsp) -- correct
_ = self.published.fetchAdd(1, .monotonic); // lock xadd %rdi,(%rsi)
// sete 0x51(%rsp) -- bogus, reads EFLAGS from the xadd
return if (idle) old else null; // branches on 0x51, not 0x50
```
That function returns `null` for every input. `fetchAdd` is the only trigger: a plain `+= 1`, an atomic `load`, and an atomic `store` in the same slot all compile correctly, and inserting any call (including `std.debug.print`) between the comparison and the branch forces a spill that hides it. Build the same file with `-fllvm` or `-OReleaseSafe` to confirm a suspected instance.
`Owner.published` in `src/upstream/owner.zig` is a plain `u64` under the owner's mutex for this reason. Do not "modernize" it to `std.atomic.Value(u64)`.
## Regenerating the contract samples
`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:
+70 -2
View File
@@ -4,7 +4,61 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [Unreleased]
## [0.0.14] - 2026-08-28
Schema changes stop costing you your query history. querylog.db is now version-stamped and migrated in place; the server refuses to start rather than ever reset a healthy file, and the release tooling refuses to ship a schema change that is neither migratable nor explicitly disclosed with recovery steps. Three releases (0.0.6, 0.0.9, 0.0.12) each discarded the log on upgrade; this ends that.
### Changed
- **querylog.db is migrated in place.** The file now carries a schema version, and a release that changes the schema ships a migration that runs at startup: one consistent backup (`querylog.db.pre-migrate-<timestamp>`, mode 0600, only the most recent kept), then every step and the version stamp in a single transaction. A failure before the commit rolls back and leaves your file exactly as it was.
- **The server refuses instead of resetting.** A querylog.db it cannot use — newer than the binary, older than 0.0.12, or mid-migration failure — is left untouched and the server exits with a clear message instead of setting the file aside and starting an empty log. The exit code (2) tells systemd not to restart-loop a deliberate refusal. Corruption is the only case that still sets a file aside automatically.
- **The release gate now enforces the contract.** A schema change cannot be tagged unless it either ships a working migration (proven in CI against a frozen fixture of the previous schema, with shipped migration files locked byte-for-byte once released) or explicitly declares a break — which requires a version bump the server refuses on, a reset disclosure, and step-by-step restore instructions in this file.
**One hazard to know when downgrading.** The first start under this release restamps querylog.db from the old fingerprint to version 1 (contents untouched). If you later downgrade to 0.0.13 or older, that binary treats the new stamp as a schema mismatch, moves your file aside as `querylog.db.schema-changed-<timestamp>`, and starts an empty log. To recover: return to 0.0.14 or newer, stop the server, move the empty `querylog.db` away and delete its `querylog.db-wal` and `querylog.db-shm` files (leaving them would corrupt the restored file), rename the `.schema-changed-<timestamp>` file back to `querylog.db`, and start.
## [0.0.13] - 2026-08-27
The upstream query budget becomes one honest deadline. A busy network no longer blames a healthy standby for running out of time, and a query burst no longer queues invisibly until everything answers SERVFAIL at once.
### Fixed
- **The per-query upstream budget is now one absolute deadline, spent by everything that blocks.** Waiting for a free slot on a saturated upstream now spends the query's `upstream.total_timeout_ms` budget just like the exchange itself, instead of being invisible to it — under a burst, queries used to wait out their whole budget in the queue and then start attempts they could never finish. An attempt near the end of the budget runs truncated, and when a truncated attempt runs out of time that is evidence about the budget, not the upstream: it no longer counts against that upstream's health or success rate, and the query log no longer names an upstream that was given no fair chance. A field incident produced 279 rows blaming a standby whose health counters read zero for zero; those rows now attribute nothing.
- **A query no longer blocks behind a saturated upstream while another has capacity.** Admission sweeps the upstreams in priority order and takes the first free slot; priority now means the order among upstreams that can be admitted right now, and a query blocks only when nothing has capacity — on the highest-priority eligible upstream, bounded by the remaining budget.
- **Conditional forward zones spend `upstream.read_timeout_ms` once per query.** A UDP attempt, a truncated answer and the TCP retry now share the one budget instead of taking a fresh one each, so a slow zone resolver can no longer stretch a single query to several times the configured timeout.
### Added
- **`nxdns_upstream_budget_exhausted_total`.** A pool-wide counter of queries whose budget ran out — in the queue or mid-attempt — before any upstream answered. It carries no per-upstream label on purpose: running out of budget is a fact about the pool.
- **A configuration with more than 64 enabled upstreams is rejected at validation** with a clear message, instead of tripping an internal limit at startup.
## [0.0.12] - 2026-08-27
Overview stops re-reading the whole query log. One endpoint, one snapshot, pre-aggregated buckets — a 30-day view now costs the same on a month of history as on a day of it. Read the upgrade note first: it resets your query history.
### Changed
- **Upgrading resets your query history.** The query-log schema gains the aggregate tables described below, and `querylog.db` is never migrated: the first start after the upgrade sets the old file aside (kept on disk next to the new one, named with the reason) and begins a fresh log. Settings, groups, blocklists and every other configuration are untouched.
- **The Overview is served by one endpoint, `GET /api/overview`.** It replaces `GET /api/stats`, `/api/stats/timeseries`, `/api/stats/types`, `/api/stats/routes` and `/api/stats/clients`, which are gone. The five panels now come from a single database snapshot, so they can no longer disagree with each other, and the page shows one loading and one error state instead of five.
- **Query statistics are pre-aggregated as they are written.** The query log now maintains 30-minute aggregate tables in the same transaction that stores the rows, and the 24-hour, 7-day and 30-day views read those instead of scanning every logged query. The cost of opening the Overview no longer grows with the size of the log: measured at three million rows, the 30-day view went from roughly eight-tenths of a second of scanning to under fifty milliseconds, at the price of about ten percent on each background write batch and ~1.5 MB of disk. The server also keeps the most recent response per period in memory and serves repeat polls from it while nothing has changed — until new queries land, retention prunes, or the period's time window rolls forward — so on a quiet network most of the steady 30-second refreshes do no database work at all.
The Overview charts move to visx and grow up: one hover treatment across all four, honest labels, and maintained d3 math under the app's own rendering.
### Changed
- **The Overview charts are drawn with visx.** The hand-written chart layout code is replaced by visx 4.0.0 primitives — maintained d3 math for the scales, ticks, stacking and arcs — while the rendering, colours and themes stay the app's own. The charts read as before, with four behaviour improvements: all four charts now share the same hover treatment (the per-client chart and both donuts gain the tooltip and dimming the query timeline already had, so pointing at a ring segment names it, its count and its share), an open tooltip follows a data refresh instead of showing stale counts, and it retires cleanly when the time window rolls. The admin bundle grows by about 68 KB and stays under its size budget.
- **The query timeline's third series is called "Allowed".** What the chart called "Other" is every query that was neither blocked nor served from cache — answered upstream, from a local record or a forward zone — so it is now named for what it is rather than for the subtraction that produces it. It stays on the chart even in a window where nothing was allowed, alongside Blocked and Cached: all three name a kind of answer a query can get, and a period where every query was blocked or cached is worth seeing.
- **The client chart drops "Other" in a window where it counted nothing.** That series aggregates the clients outside the top eight, so when it counts nothing there is nothing being aggregated, and a legend entry, a tooltip row and a table column that exist only to say "zero" are noise. The named clients stay even at zero, because a client that went quiet is a fact about the window.
## [0.0.10] - 2026-08-24
Configuration goes live: when the database owns the configuration, saving a setting reconfigures the running process instead of asking for a restart. The restart-required set shrinks to the listen sockets and the admin interface switch.
### Changed
- **Almost every settings change now applies while the server runs.** When the database owns the configuration, saving a setting takes effect immediately — the blocking response, upstream timeouts, cache size, rate limits, session lifetime, log level and destination, blocklist update schedule, privacy flags, disk thresholds and the query-log buffer all reconfigure the running process, exactly as Pi-hole and AdGuard Home do. Nothing is written to the database unless the running server already accepted it, so the API can never report a value the process refused. The restart-required set shrinks from every scalar key to the twelve that genuinely need one: listen addresses and ports, and turning the admin interface itself on or off. The admin pages drop their restart notices for everything else, and an upstream edit — the loudest offender — now applies to the next query. A configuration file still works the way it always has: edit the file, restart the process.
- **The release cut refuses to ship an undisclosed query-log schema change.** `zig build cut` now compares the `querylog.db` schema fingerprint of the previous release tag against this tree's, and when they differ it requires the changelog section for the version being cut to state that the upgrade discards the stored query history. 0.0.9 changed the schema and its announcement did not mention it; the file is never migrated, so that upgrade silently threw every logged query away.
## [0.0.9] - 2026-08-22
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.
@@ -13,22 +67,36 @@ Query provenance: every logged query becomes exactly explainable — what the po
- **Every logged query has a detail page.** A row in Activity now links to `/activity/queries/{id}`, which explains that one query in the order it was decided: the request, the group it was matched under, the policy verdict with the rule that produced it and the blocklist source that rule came from, any CNAME uncloaking or safe-search rewrite, the route the answer took — blocked, local, forward zone, upstream or cache — and what the client got back, RCODE and duration included. `GET /api/queries/{id}` serves the same object; an id that retention has already deleted is a 404. The live view carries the same provenance for the queries it streams, so a query is explainable as it happens as well as afterwards.
- **Query Log, Live and Lookup are one Activity page.** `/activity` is the single surface for what nxdns answered: History reads the stored log, Live reads the stream, and both show the same seven columns — Time, Domain, Client, Type, Result, Route, Duration. The mode and every filter live in the URL, so an investigation is one link that shows the recipient exactly what you were looking at, and an absolute time range stays that range instead of drifting as the day goes on. A new **Result** column says what the client actually got — `Blocked`, `NOERROR`, `SERVFAIL` and the rest — with the **Route** column beside it saying how the answer was produced, which is the pair the old Status column could not show: a blocked name is answered with NOERROR, and reading only the code made a block look like a success. Both unhappy cases are marked by weight and shape as well as colour. Switching between History and Live keeps your filters, and leaving Live closes the stream instead of holding a viewer slot open. A live row that the log has not written yet opens its own provenance in place — no invented row id — and the open detail stays put while the 500-row buffer scrolls past underneath it. Domain testing moves to `/activity/test` as **Current policy simulation**, worded so it can never be misread as an account of a query that already happened.
- **Diagnostics can be scoped to an absolute window.** `/diagnostics?since=…&until=…` now validates and applies both bounds to the active and resolved lists, and the page states the window it is showing with a way to clear it. A query's detail page links here with the five minutes either side of that query, which is where the underlying failure text for a SERVFAIL lives.
- **The query log and the dashboard say how far back the history goes.** `GET /api/queries`, `/api/stats` and `/api/stats/timeseries` each carry a `coverage` object: `available_since`, the first second the file can answer for, and `complete`, whether the window you asked for begins inside it. A period that starts before the query log does now says so instead of charting the missing part as zero — which is what a recreate, a retention pass or a fresh install would otherwise look like.
- **The query log and the stats endpoints say how far back the history goes.** `GET /api/queries` and all five `/api/stats*` endpoints each carry a `coverage` object: `available_since`, the first second the file can answer for, and `complete`, whether the window you asked for begins inside it. A period that starts before the query log does now says so instead of charting the missing part as zero — which is what a recreate, a retention pass or a fresh install would otherwise look like.
- **Three new period breakdowns: `GET /api/stats/types`, `/api/stats/routes` and `/api/stats/clients`.** They take the same `period` parameter as `/api/stats` and report over the same UTC-aligned window, so every panel of one page describes the same span. `types` counts queries per DNS type, with the queries that recorded no type kept as their own row instead of dropped — the numeric type only, since naming types is the admin's job and a second table in the server would drift out of agreement with it. `routes` counts queries by how they were answered, grouping upstream rows by the answering resolver and forward-zone rows by the zone, with blocked, cache, local and rejected answers carrying no source. `clients` returns one bucketed series per client, aligned exactly like `/api/stats/timeseries` so the two charts share an x-axis: the eight busiest clients in the window are named and everything else sums into an `other` series, which is always present and always the same length as the named ones.
- **`/metrics` says whether an upstream is queueing.** Five new per-upstream series, labelled by index and redacted url like the existing ones: `nxdns_upstream_in_flight` and `nxdns_upstream_slots` are the exchanges in flight against an upstream and the ceiling they cannot cross, `nxdns_upstream_queued_total` and `nxdns_upstream_queued_seconds_total` count the exchanges that had to wait for a slot and the time they spent waiting — including the ones that were cancelled while waiting, which is exactly the query that ends in SERVFAIL — and `nxdns_upstream_reuse_recoveries_total` counts the stale DoT connections that were redialled, so connection churn is a number instead of log noise. The two queue counters are approximate: they are sampled when a query is admitted, not measured as a queue length. `/api/health` is unchanged.
- **Each of those responses is read atomically.** Every window-bounded read — the five stats endpoints and `GET /api/queries` — now takes its rows and its coverage watermark inside one SQLite read transaction. A retention pass that runs mid-response can no longer hand back rows from before the prune tagged with an `available_since` from after it, and the clients breakdown ranks and buckets from one database state rather than two. The transaction is a deferred read, so it never blocks the query logger or retention.
### Removed
- **The upstream-history subsystem and `GET /api/upstream/health` are gone.** nxdns recorded every upstream exchange into per-minute aggregates in `querylog.db` so the dashboard could show each upstream's counts, success rate and last failure over the selected period. The Overview replacement drops that table, which left a writer whose only reader was its own failure signal, so the whole subsystem goes: the accumulator and its flush task, the `upstream_targets` and `upstream_minute` tables, the `/api/upstream/health` endpoint, the retention pass over the minute rows, and the four `nxdns_upstream_history_*` and one `nxdns_retention_upstream_rows_pruned_total` Prometheus metrics. What replaces it: `/api/health` says how many upstreams are available of how many enabled, `/metrics` keeps the live per-upstream `nxdns_upstream_up` and `nxdns_upstream_success_rate` series, and a failing upstream is a Diagnostics episode (`upstream.exchange`) with its own error text and duration. Ranged per-upstream counts are not replaced. Existing `upstream_history.write` diagnostics entries stay readable; nothing writes new ones, and any that were still open when you upgrade are closed at the first start.
- **`/queries`, `/queries/{id}`, `/live` and `/lookup` are gone, and bookmarks to them break.** There is no redirect and no alias: the paths simply stop resolving, and the app shows its not-found page. Everything those pages did is on `/activity`, `/activity/queries/{id}` and `/activity/test`. Three navigation entries collapse into one, "Activity". The API is untouched — `/api/queries`, `/api/queries/{id}`, `/api/queries/live` and `/api/lookup` all answer exactly as before.
- **The Status column, and the block reason on every row.** The reason a query was blocked was repeated on each of a hundred rows and pushed the answer the client saw off the table. Result and Route replace it; the exact rule, the blocklist source and the historical group stay one click away on the query's detail page, which is the only place they were ever readable.
### Changed
- **Configuration is three task-shaped pages, and a file-managed server says so once.** Groups, Blocklists, Rules, Local DNS, Upstreams and Settings — six navigation entries and six routes — become `/configuration/protection`, `/configuration/resolution` and `/configuration/system` under one labelled "Configuration" group. Protection is group-centred: pick a group and read its safe search, its blocklist sources, its rules and how many clients it filters, with the shared blocklist catalogue and "Update now" on a second tab. Resolution holds the upstream pool, local records and forward zones; System holds the settings sections, the restart-required annotations and "Reload certificates". The tab and the selected group are URL state, so the view you are reading is a link, and the six old paths stop resolving — no redirect, no alias. When a configuration file owns the server, these pages are no longer forms with everything greyed out: they render as definition lists and tables with the exact ZON key beside each value, a note saying which file to edit, and no input, checkbox, Add, Edit, Delete or Save anywhere — while runtime actions that do not touch the file (update blocklists now, reload certificates, pause and resume, deleting an observed client) stay ordinary working buttons. A new `GET /api/config/status` is the one place that answers who owns the configuration and whether a restart is pending; the admin never guesses from a 403, and until the endpoint answers nothing is editable. `restart_pending` is now the server's own state rather than a banner one browser tab remembered: it is set by the upstream and settings changes that need a restart, it survives a page reload, another tab sees it within a minute, and only restarting the process clears it. `GET /api/settings` loses its `authority` object, which said the same thing twice. Clients keeps its own top-level page, because naming an unknown device is operations, not configuration: the list is address, name, group and first/last seen, `?group=` filters it to one group, and each row opens `/clients/{id}` — the client's identity and where its name came from, the group whose policy it follows with a link to that group, and a link into Activity for its last 24 hours.
- **The Dashboard is now Overview, and it takes Pi-hole's layout.** `/` redirects to `/overview`, and the page answers one question — what the resolver did over a period you choose — instead of laying out six widgets. The 1h/24h/7d/30d period is URL state (`/overview?period=1h`), so the view you are reading is a link you can send. Top to bottom: four neutral stat tiles — queries, blocked with its share, distinct clients, average response time — each linking into the rows behind its number; the query-volume timeline split blocked, cached and other; a new per-client chart on the same axis, the busiest clients named — by their registered or reverse-DNS name where they have one, exactly as the query tables name them — and the rest summed as "other"; and two donuts, query types and how queries were answered, with each upstream and forward zone named separately. Colours follow the identity of a client, a type or a route rather than its rank, so one client overtaking another between refreshes does not repaint the page. Each donut is drawn as decoration with a visible legend beside it and a table a screen reader reads instead of the graphic. All five panels describe one window — matched on the period, both bounds and the coverage watermark together — so a refresh that straddles a bucket boundary, or a retention pass mid-page, can never put a headline count above a chart of a different span. Panels load, fail and retry on their own: a failing donut leaves the charts standing. A period with nothing in it says "No queries in this period." rather than drawing an empty frame.
- **The five health conditions moved to Diagnostics, and the nav item says when to look.** Protection, Upstreams, Query history, Diagnostics and Storage are now a compact strip at the top of `/diagnostics`, above the episodes that explain them, instead of a status list on the landing page. Each states its state in words and an icon as well as colour. A healthy condition is quiet; a degraded one is highlighted and links to what can fix it: protection to Blocklists, no reachable upstream to Upstreams, and a losing or failed query log or a low or critical disk to this same page filtered to the component that failed, with any time window cleared so the filter cannot hide the episodes it points at. Dropped rows are reported with the time of the newest drop, so a loss stays visible after the box recovers. When a health poll fails, the conditions on screen are labelled as the last reading that arrived rather than passing for the current state, and a Retry sits beside them. The Diagnostics navigation item carries a badge with the number of open episodes; it shows a plain "!" when the rollup is degraded with nothing open, and also when the last health poll failed, because an unknown must not look like good news. The badge is absent only when health answered and there was nothing to report.
- **Pause moved to the sidebar, and the header indicator is gone.** The header carries nothing but the menu button and Log out. Pause and Resume sit at the foot of the navigation sidebar, above the version label, in both the desktop rail and the phone drawer — one global runtime action in the one place that belongs to the resolver rather than to whichever page you are on. The control still appears beside the detail of a query that was blocked, which is the other place the action answers what you are looking at. The control says what it is doing as well as what it offers: Pause while filtering is on, and while it is off, "Paused until 14:05" — or plain "Paused" when the pause has no end — above the Resume button, on every page. "Resume" on its own would name an action without naming the state it ends, and with the header indicator gone no other page could tell you filtering was off. Both controls read the same `protection` condition, so they cannot disagree, and a pause or resume is reflected immediately rather than at the next poll. Nothing offers to pause while protection is unavailable, since pausing a resolver with no filter snapshot changes nothing.
- **`GET /api/health` changed shape completely.** The body is now `status` plus five condition objects — `protection`, `upstreams`, `query_history`, `diagnostics` and `disk` — and `status` is `degraded` when, and only when, one of them is in a degrading state: protection `unavailable`, upstreams `unavailable`, query history `losing` or `failed`, diagnostics `unavailable`, or disk `low` or `critical`. Nothing can degrade the rollup without appearing in the response any more; the old hidden upstream-history contribution was the reason for the rewrite. A paused protection is reported and does not degrade, because it is a choice you made rather than a fault. `queries_dropped` and `writer_failed` fold into `query_history`, which also carries `last_drop_s`, the time of the newest dropped row; `refreshes_gated` and `snapshot_generation` leave the body and stay in `/metrics`, as do the disk `db_bytes`, `log_bytes` and `sample_failures` fields. The disk monitor's `warn` state is reported as `low`, because `warn` reads as a log level rather than as a quantity of disk. This is a breaking change to a documented endpoint, taken pre-v0.1 rather than carried.
- **Upgrading resets your query history a second time.** Dropping the `upstream_targets` and `upstream_minute` tables changes the `querylog.db` schema fingerprint, and that file is never migrated, so the first start after this release sets the old one aside as `querylog.db.schema-changed-<unix seconds>` and creates a fresh one — exactly as the provenance change above does, and in the same start. `config.db` is untouched.
- **`GET /api/stats` no longer reports `cached`.** The standalone cache card is gone from Overview, so the totals field behind it has no consumer. Cache hits stay visible in the query-volume timeline's blocked/cached/other split, in `GET /api/stats/timeseries`, and in `nxdns_cache_hits_total`.
- **`GET /api/queries` rows changed shape.** Each row gains `qclass`, `rcode`, `policy_action`, `policy_reason` and `route_kind`, and `block_reason` is gone: the reason a query was blocked is now one of a closed set of values rather than a formatted string. No table column shows it — the reason is read on the query's detail page, and by an API client from `policy_reason` on the row. `blocked`, `cache_hit`, `upstream` and every other existing field are unchanged.
- **Upgrading resets your query history.** The `query_log` table gains the provenance columns below, and `querylog.db` is never migrated (it holds expendable log rows, so a schema change replaces the file instead of upgrading it). On the first start after the upgrade the old file is set aside as `querylog.db.schema-changed-<unix seconds>` and a fresh one is created. Nothing else is touched: `config.db` keeps your configuration and your diagnostics history. The recreate files a resolved `query_log.recreated` diagnostics entry naming the file that was kept and the timestamp the new history begins at, and a new `querylog_meta` table records that coverage start, so the dashboard can say "history is available from ..." instead of charting an empty range as zero. The set-aside file is a working SQLite database and can be deleted once you have decided you do not want it.
- **`logging.query_log_buffer_max` now accepts 1 to 37449, down from 1 to 1000000.** The queued entry carries every new provenance field by value and is about four times as wide as before — 1792 bytes against 432 — so the meaningful bound is bytes rather than entries. The ceiling is computed at compile time from the width of the entry so that the queue's worst case stays within 64 MiB, and it moves whenever that width does. The default of 10000 is unchanged and costs about 17 MiB. A configuration above the new ceiling is rejected at startup with the ceiling in the message.
- **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`.
- **The query log returns to SQLite's default checkpoint cadence.** 0.0.8 stretched `wal_autocheckpoint` on every read-write `querylog.db` connection from the 1000-page default to 8192 pages, on the expectation that it would cut about 130 MiB a day of checkpoint writeback on the deployed Pi. Field measurement on that Pi showed no measurable effect on daily disk writes, so all it bought was a roughly five-hour power-loss durability window in place of the default's ~40 minutes. No pragma is issued any more: the cadence is SQLite's 1000 pages, about 4 MiB, and the ~40-minute boundary is back.
- **The admin bundle now has a ceiling the build enforces.** `npm run build` fails if `admin/dist/assets` totals more than 800,000 bytes — it is 708,352 today — and prints the largest chunks when it does. The bundle is embedded in the server binary and served to your LAN, so an accidental dependency arriving in it is a regression every other check would have passed. Alongside it the redesign's closure sweep removed the last code the new pages left behind: an unused API client call and type, and the `features/queries` directory renamed to `features/provenance` now that no page lives there. The investigation links that carry a time window out of a query detail are pinned by their own tests, including one that a link's emitted bounds survive the Activity page's validation unchanged. Nothing an operator uses changed.
### Fixed
- **A burst of concurrent queries no longer resolves one at a time, and no longer ends in SERVFAIL.** The pool held an upstream for the whole of an exchange, so every query against one upstream waited for the one before it, and the DoT client dialled a fresh TCP connection and ran a full TLS handshake for each query on top of that. Thirty concurrent names against one DoT upstream resolved as a staircase at about 92 ms per query on a Pi, and the queries at the back of the queue burned the five-second total budget waiting and were answered SERVFAIL — with nothing on any surface saying a queue existed. Two changes fix it: each upstream now runs up to eight exchanges at once, each on its own leaf client behind a semaphore, so a query waits for a free slot rather than for the whole upstream; and a DoT client keeps its TLS session open across exchanges instead of handshaking per query. A reused connection that the resolver closed while it was idle is detected at use — never by a keepalive timer — and redialled once, and that redial is invisible to health and to Diagnostics because an idle close is normal, not a fault. The slot count is compiled, not configured: there is no new knob.
- **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
+18 -33
View File
@@ -25,9 +25,9 @@ Serves a household LAN (≈220 devices). Portfolio-grade public repo with ext
- Domain filtering: blocklists (hosts/domains/ABP, including `@@||name^` exception lines), custom rules (allow/block; exact, parent-walk, wildcard, regex), CNAME uncloaking (depth 8), per-group safe-search rewrite.
- DNS caching: positive + negative, in-memory only.
- Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments.
- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, dashboard + time buckets, live SSE stream.
- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, Overview + time buckets, live SSE stream.
- Web app + REST API: LAN/Tailscale admin UI, optional password auth, OpenAPI schema + CI contract tests.
- Observability: upstream health API + UI, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
- Observability: `/api/health` conditions surfaced as a Diagnostics badge and health strip, live upstream availability in `/metrics`, a failing upstream as a Diagnostics episode, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
- Ops: config authority chosen by the invocation (database, or a file named by `--config`), `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose.
### 2.2 Out of Scope (permanent scope decisions, not deferrals)
@@ -85,13 +85,13 @@ Verified: 0.16.0 ships `std.crypto.tls.Client` only. There is no server-side TLS
Two SQLite files with opposite write profiles, isolated from each other:
- **`config.db`** — small, precious, rarely written: groups, clients, prefixes, upstreams, blocklist source metadata, rules, local records, forward zones, settings, schema version.
- **`querylog.db`** — high-churn, large, expendable: query log + its own private `domains` dimension table. Client identity stored as **IP text**, not a FK into config — log rows are immutable facts and must not point at mutable config rows. If `querylog.db` is missing or corrupt at startup, rename aside, recreate, keep serving. Log loss is not an outage.
- **`querylog.db`** — high-churn, large, expendable: query log + its own private `domains` dimension table. Client identity stored as **IP text**, not a FK into config — log rows are immutable facts and must not point at mutable config rows. If `querylog.db` is missing or corrupt at startup, rename aside, recreate, keep serving — corruption only; a healthy file whose schema this build cannot use refuses the startup instead (§3.7).
- No cross-DB references. Retention/VACUUM churn never touches `config.db`; config backup is a copy of a tiny file.
### 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. 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.
- `querylog.db`: a logical version in `PRAGMA user_version`, migrated **in place** at startup by the same shape of compiled step list, inside one transaction and behind one `querylog.db.pre-migrate-<epoch>` backup (only the newest is kept). A healthy file is never renamed aside: a version this build cannot reach refuses the startup with instructions, and only corruption recreates. A deliberate break is still allowed, but it must be versioned, refused at startup, and disclosed in the changelog — the cut gate enforces that. See `docs/reference/query-log-lifecycle.md`.
### 3.8 Blocklist Storage (Decision A)
@@ -238,7 +238,7 @@ src/
web/
server.zig router.zig auth.zig sse.zig static.zig metrics.zig openapi.zig
handlers/
auth.zig stats.zig queries.zig clients.zig groups.zig blocklists.zig
auth.zig overview.zig queries.zig clients.zig groups.zig blocklists.zig
rules.zig local.zig lookup.zig pause.zig settings.zig
upstream_health.zig certs.zig health.zig version.zig
@@ -324,8 +324,10 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
## 9. Upstream Resolution
- Schemes: `https://…` → DoH, `tls://host:853` → DoT.
- Ordered by priority; sequential attempt; per-upstream failure counters; exponential backoff with jitter; success resets.
- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. This is routing state: it drives failover and backoff, and is exposed through `/metrics` and `nxdns check`. `GET /api/upstream/health?period=…` exposes none of it except the live `enabled`/`available` pair; its counts, success rate and last failure are ranged aggregates read from the per-minute upstream history in `querylog.db`, so the dashboard's period scopes them like every other number on the page.
- Ordered by priority; one query tries upstreams sequentially; per-upstream failure counters; exponential backoff with jitter; success resets.
- Concurrency is per upstream, not per pool: each entry owns `slots_per_entry = 8` leaf clients behind a semaphore, so at most 8 exchanges are in flight against one upstream at a time and the rest wait for a slot rather than for the whole entry. The slot count is compiled, not configured. A task that finds every slot taken counts itself in `queued_total`/`queued_seconds_total` on both exits — acquisition and cancellation — so a query that burned its total budget waiting is visible on `/metrics` (`nxdns_upstream_queued_total`, `nxdns_upstream_in_flight`, `nxdns_upstream_slots`) instead of being an unexplained SERVFAIL. The counters are admission samples, not an exact queue length.
- DoT keeps its connection: a `DotClient` holds one TLS session open across exchanges rather than dialing and handshaking per query. Staleness is detected at use, never by a keepalive timer — a reused session that fails before the first response byte with a connection-lifecycle error is redialed once and retried, and that recovery counts in `nxdns_upstream_reuse_recoveries_total` without touching health or emitting a diagnostics episode. Every other failure closes the session and classifies as before.
- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. This is routing state: it drives failover and backoff, and is exposed through `/metrics` and `nxdns check`. There is no per-upstream API surface: `/api/health` reports the pool as available-of-enabled, and a failing upstream is a Diagnostics episode (`upstream.exchange`).
- DoH client: `std.http.Client` with `content-type/accept: application/dns-message`; strict status + payload checks.
- `platform/tls_client.zig` enforces per-connection read/write deadlines, classifies TLS errors explicitly, retries with backoff. Integration tests cover timeout/hang scenarios so compiler upgrades can't silently regress them.
- Connect, read, and total-budget timeouts each configurable.
@@ -480,26 +482,10 @@ 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);
```
The sketch above is the original shape; `src/storage/querylog_schema.zig` is the authority, and the provenance columns milestone 28 added are not repeated here. Beside the raw rows the file carries four projection tables — `bucket_totals`, `bucket_clients`, `bucket_types`, `bucket_routes` — on a 30-minute grain, which is what `GET /api/overview` reads for the 24h, 7d and 30d windows instead of scanning every row. They are maintained by the batch writer and by retention inside the same transaction as the raw rows, so SQLite's transaction is the whole coherence story: no second file, no backfill, no rebuild command. The 1h window is narrower than the grain and takes one raw scan.
### 11.4 Query Logger
- In-memory buffer, mutex guarded, hard cap `query_log_buffer_max` (default 10000).
@@ -509,7 +495,7 @@ CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
### 11.5 Retention
Periodic delete of rows older than `retention_days`; scheduled checkpoint/VACUUM on `querylog.db` only.
Periodic delete of rows older than `retention_days`, dropping the projection buckets behind the cutoff and recomputing the straddling one in the same transaction; scheduled checkpoint/VACUUM on `querylog.db` only.
### 11.6 Disk Discipline (cloudflared lesson)
@@ -552,7 +538,7 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
### 13.1 Endpoints
- `POST /api/auth/login`, `POST /api/auth/logout`
- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…`
- `GET /api/overview?period=…` — every Overview panel in one response over one read transaction
- `GET /api/queries` (filter + paginate), `GET /api/queries/live` (SSE, per-IP cap)
- `GET/PUT /api/clients/{id}`
- `GET/POST/PUT/DELETE /api/groups…`, `/api/blocklists…`, `/api/rules…`, `/api/local-records…`, `/api/forward-zones…`
@@ -560,9 +546,8 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
- `GET /api/lookup?domain=…&group_id=…`
- `GET/POST /api/pause`
- `GET/PUT /api/settings`
- `GET /api/upstream/health?period=…`
- `POST /api/certs/reload`
- `GET /api/health`overall + disk + upstream + queries_dropped rollup
- `GET /api/health`five condition objects (protection, upstreams, query history, diagnostics, disk) and the status computed from exactly their states
- `GET /metrics` — Prometheus text exposition: query counters (total/blocked/cached), per-upstream health, cache stats, queries_dropped, disk gauges
- `GET /api/version`, `GET /api/openapi.yaml`
@@ -574,7 +559,7 @@ Hand-maintained `openapi.yaml`, served at `GET /api/openapi.yaml` and mirrored b
## 14. Frontend
Pages: Dashboard (stats + upstream health + disk), Query log, Live log, Clients, Groups, Blocklists, Rules, Local DNS (records + forward zones), Domain lookup, Settings.
Pages: Overview (stat tiles, queries over time, client activity over time, and query-type and upstream breakdowns, all over one period), Query log, Live log, Clients, Groups, Blocklists, Rules, Local DNS (records + forward zones), Domain lookup, Settings.
Requirements: responsive desktop/mobile; route loaders for initial fetch; TanStack Query for cache/retries; error/loading states on every data view; works with auth enabled or disabled; restart-required banner.
@@ -632,7 +617,7 @@ systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/v
- **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON loading + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade).
- **Fuzz**: DNS parser malformed-packet fuzzing; blocklist parser fuzzing.
- **Integration**: UDP/TCP query path; blocked path; allow-over-block; wildcard precedence; CNAME uncloaking block; local records + forward zones; upstream failover/backoff/health; disk-full degradation; querylog.db corruption recovery; API CRUD; auth on/off; SSE; contract tests.
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log.
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; Overview + live log.
---
@@ -681,7 +666,7 @@ The project publishes released binaries and container images from its own Gitea
6. Disk-fill degrades gracefully; no silent log-flood failure mode.
7. Web UI + API provide full admin functionality; OpenAPI contract tests green.
8. `nxdns export` round-trips via `nxdns import`.
9. Query logging, stats, SSE live stream work; querylog.db corruption self-heals.
9. Query logging, the overview, SSE live stream work; querylog.db corruption self-heals.
10. Local DoH + DoT endpoints serve LAN clients.
11. Schema upgrade = install + restart (migration test proves it).
12. All suites green in Gitea CI for both targets.
@@ -709,5 +694,5 @@ The project publishes released binaries and container images from its own Gitea
| G | SQLite vendored amalgamation + own thin wrapper |
| H | Two DBs: `config.db` (precious) + `querylog.db` (expendable, self-contained, client IP as text) |
| I | Frontend embedded in binary; dev flag serves from disk; static musl release builds |
| J | Auto-migration for `config.db` at startup; `querylog.db` recreated on mismatch |
| J | Auto-migration at startup for both databases; `querylog.db` recreated only when corrupt |
| — | Safe-search per-group; Prometheus `/metrics` in scope; CI on self-hosted Gitea Actions |
+508 -3
View File
@@ -11,6 +11,12 @@
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
"@visx/axis": "4.0.0",
"@visx/grid": "4.0.0",
"@visx/group": "4.0.0",
"@visx/scale": "4.0.0",
"@visx/shape": "4.0.0",
"@visx/tooltip": "4.0.0",
"react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8"
@@ -1619,6 +1625,84 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
"license": "MIT"
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz",
"integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1633,6 +1717,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
@@ -1647,7 +1737,7 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1657,7 +1747,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -2003,6 +2093,211 @@
"node": ">=16.20.0"
}
},
"node_modules/@visx/axis": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/axis/-/axis-4.0.0.tgz",
"integrity": "sha512-cSPNO9Aic2UX49AmIeJJ9TQrrAkvUHpHOGqimUAXhbg07VI3HoHDKcXZdrjvQz+g0LhubrUkjj8Gwju2WZ3dtg==",
"license": "MIT",
"dependencies": {
"@visx/group": "4.0.0",
"@visx/point": "4.0.0",
"@visx/scale": "4.0.0",
"@visx/shape": "4.0.0",
"@visx/text": "4.0.0",
"classnames": "^2.3.1"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@visx/bounds": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/bounds/-/bounds-4.0.0.tgz",
"integrity": "sha512-3wAfN5fg2bxg/5f3MlKWzGDTKU2hdKkxq8bVWXpPZQV0UiVc0myuU1qa34c7tN3hg5SDe6MJi/bae2eTQDgEiQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@visx/curve": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.0.tgz",
"integrity": "sha512-bXrOSd2BzVCxR7kBE7gPTSU4plxzYN75w7erdHYabwn3vzP+xFk1o0gg3NOHEPJzt5tXqf0Ecm5YqHACOnipTA==",
"license": "MIT",
"dependencies": {
"@visx/vendor": "4.0.0"
}
},
"node_modules/@visx/grid": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.0.tgz",
"integrity": "sha512-BUnznqYo8jyn3zXRWZiF0zwg1VQl6w5kYqZ+XMY+cPXtAHZZbwGXBChMpPefH5Lj1+P1b4Pf3QihKG8zjL9PCw==",
"license": "MIT",
"dependencies": {
"@visx/curve": "4.0.0",
"@visx/group": "4.0.0",
"@visx/point": "4.0.0",
"@visx/scale": "4.0.0",
"@visx/shape": "4.0.0",
"classnames": "^2.3.1"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@visx/group": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.0.tgz",
"integrity": "sha512-HGAq8BCqn5x0t94CJOLJeKtWss9LFpF3HY69HbuO1PlR+B9c4aVT9xbjfudmU8wGQZLT6JNt+PJvxbO2aJ+1aA==",
"license": "MIT",
"dependencies": {
"classnames": "^2.3.1"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@visx/point": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.0.tgz",
"integrity": "sha512-hwJ9UlIjg3iV4iiJ3RN5SNTXama+zGaNe57sXSrSvPiqhkDXypsYBdDSlAnHmKbMzHP0zFmgB1UJciB/QUyigw==",
"license": "MIT"
},
"node_modules/@visx/scale": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.0.tgz",
"integrity": "sha512-Q3VaSLKlsrxxSl9CwS2D3Mvz4/4kIp/6NMKRY8Im0Au1hrQh2egU806p0+JwN0ccuGGDe03jyAxX4BSRAH8Plg==",
"license": "MIT",
"dependencies": {
"@visx/vendor": "4.0.0"
}
},
"node_modules/@visx/shape": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.0.tgz",
"integrity": "sha512-X0FP3OFjQhc5/6Vj2aY7cK2JwKYI0H0hQM7cdNRWy/ZMMlrh5KpEdqx0HVo0KoTeTlkUvO+kydMLnAuuCRFIFw==",
"license": "MIT",
"dependencies": {
"@visx/curve": "4.0.0",
"@visx/group": "4.0.0",
"@visx/scale": "4.0.0",
"@visx/vendor": "4.0.0",
"classnames": "^2.3.1"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@visx/text": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/text/-/text-4.0.0.tgz",
"integrity": "sha512-39f2goSaKy5Mqn89lRDGSJ1IMr49wplkbIFHsFI8cPnAPbguiJxYZ+ulizd7Vcsq2z/hytuGv3Lmk165zUiUYQ==",
"license": "MIT",
"dependencies": {
"classnames": "^2.3.1",
"reduce-css-calc": "^1.3.0"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@visx/tooltip": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/tooltip/-/tooltip-4.0.0.tgz",
"integrity": "sha512-+6J2h5qPvniT0pQtxQrLYGErPRVHr3pRfcMkNqTxD/HgofDGsL6vEBkjMR535MF1hkWYQ9XzOiWP6ERN4TF+mg==",
"license": "MIT",
"dependencies": {
"@visx/bounds": "4.0.0",
"classnames": "^2.3.1",
"react-use-measure": "^2.0.4"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@visx/vendor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0.tgz",
"integrity": "sha512-LoBNzWjTXzBfu095BQxB14IwZQHHOLs8ZMzM6t2FL4ZGORaACgLcmXRRLhFo0VmRR8L7iNTM4HHc1j6yFvzsAA==",
"license": "MIT and ISC",
"dependencies": {
"@types/d3-array": "3.0.3",
"@types/d3-color": "3.1.0",
"@types/d3-delaunay": "6.0.1",
"@types/d3-format": "3.0.1",
"@types/d3-geo": "3.1.0",
"@types/d3-interpolate": "3.0.1",
"@types/d3-path": "3.1.1",
"@types/d3-scale": "4.0.2",
"@types/d3-shape": "3.1.7",
"@types/d3-time": "3.0.0",
"@types/d3-time-format": "2.1.0",
"d3-array": "3.2.1",
"d3-color": "3.1.0",
"d3-delaunay": "6.0.2",
"d3-format": "3.1.0",
"d3-geo": "3.1.0",
"d3-interpolate": "3.0.1",
"d3-path": "3.1.0",
"d3-scale": "4.0.2",
"d3-shape": "3.2.0",
"d3-time": "3.1.0",
"d3-time-format": "4.1.0",
"internmap": "2.0.3"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
@@ -2211,6 +2506,12 @@
"node": ">=12"
}
},
"node_modules/balanced-match": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
"integrity": "sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==",
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
@@ -2299,6 +2600,12 @@
"node": ">=18"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2351,9 +2658,136 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
"integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -2393,6 +2827,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -2533,6 +2976,15 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -2946,6 +3398,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-expression-evaluator": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz",
"integrity": "sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==",
"license": "MIT"
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@@ -3254,6 +3712,47 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-use-measure": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
"integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.13",
"react-dom": ">=16.13"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/reduce-css-calc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz",
"integrity": "sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^0.4.2",
"math-expression-evaluator": "^1.2.14",
"reduce-function-call": "^1.0.1"
}
},
"node_modules/reduce-function-call": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz",
"integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/reduce-function-call/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3264,6 +3763,12 @@
"node": ">=0.10.0"
}
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+7 -1
View File
@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "vite",
"build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs",
"build": "vite build && node scripts/assert-css-layers.mjs && node scripts/assert-bundle-size.mjs && node scripts/stamp-dist.mjs",
"typecheck": "tsc -b",
"lint": "oxlint src vite.config.ts",
"format": "prettier --write .",
@@ -28,6 +28,12 @@
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
"@visx/axis": "4.0.0",
"@visx/grid": "4.0.0",
"@visx/group": "4.0.0",
"@visx/scale": "4.0.0",
"@visx/shape": "4.0.0",
"@visx/tooltip": "4.0.0",
"react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
// The admin bundle is embedded in the server binary and served to a household
// LAN, so an accidental dependency or a stray asset landing in dist is a
// regression nobody would otherwise notice: every other gate passes with a
// bundle twice this size. One number, total bytes of dist/assets — not per
// chunk, not gzipped — because the failure being caught is "something big
// arrived", not chunk shape.
//
// This runs from admin/ as part of `npm run build`, before stamp-dist: a failed
// size check must not leave a fresh .src-hash beside an oversized bundle that a
// later Zig build would accept as current.
import { readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const BUDGET_BYTES = 850_000;
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
let entries;
try {
entries = readdirSync(distDir, { withFileTypes: true });
} catch (err) {
console.error(`assert-bundle-size: cannot read admin/dist/assets: ${err.message}`);
process.exit(1);
}
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => ({ name: entry.name, bytes: statSync(join(distDir, entry.name)).size }))
.sort((a, b) => b.bytes - a.bytes);
if (files.length === 0) {
console.error("assert-bundle-size: no files in admin/dist/assets — did the build emit anything?");
process.exit(1);
}
const total = files.reduce((sum, file) => sum + file.bytes, 0);
const format = (bytes) => bytes.toLocaleString("en-US");
if (total > BUDGET_BYTES) {
console.error(
`assert-bundle-size: admin/dist/assets is ${format(total)} bytes, over the ${format(BUDGET_BYTES)} byte budget.`,
);
console.error("Largest chunks:");
for (const file of files.slice(0, 5)) console.error(` ${format(file.bytes).padStart(9)} ${file.name}`);
console.error("Drop what arrived, or raise the budget in this script with the reason in the changelog.");
process.exit(1);
}
console.log(
`admin/dist/assets is ${format(total)} bytes across ${files.length} files, ` +
`${format(BUDGET_BYTES - total)} under the ${format(BUDGET_BYTES)} byte budget`,
);
@@ -5,7 +5,8 @@ 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";
import { provenance } from "@/features/provenance/provenanceFixture";
import { health } from "@/lib/healthFixture";
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
return { id, ...provenance(sections) };
@@ -16,6 +17,7 @@ let responses: Record<string, unknown>;
beforeEach(() => {
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
"/api/health": health(),
};
vi.stubGlobal(
"fetch",
@@ -310,3 +312,31 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
domain: "gone",
});
});
/** The related-actions region of a query detail. */
function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" });
}
/**
* Related is links only. Pause is a resolver-wide control and lives in the
* sidebar alone, so a blocked query the case that used to carry one here
* offers no button of any kind.
*/
test("Related carries its four links and no control, blocked query or not", async () => {
responses["/api/queries/50"] = detail(50, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" },
});
renderDetail(50);
await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).getAllByRole("link").map((link) => link.textContent)).toEqual([
"Test this domain against current policy",
"All activity for this domain",
"All activity from this client",
"Diagnostics around this query",
]);
expect(within(related()).queryByRole("button")).toBeNull();
});
@@ -68,7 +68,13 @@ export default function ActivityDetailPage() {
<ProvenanceDetail
provenance={detail}
persistedId={detail.id}
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
relatedActions={
<RelatedActions
domain={domain}
client={client}
ts={time}
origin={origin} />
}
/>
</section>
);
@@ -0,0 +1,393 @@
/**
* The filter toolbar on its own: the controls that decide what reaches the URL,
* driven through a harness that plays the part the page plays.
*
* The timezone is fixed because the only invalid bound reachable in jsdom is a
* wall-clock time inside a spring-forward gap: jsdom applies the
* `datetime-local` value sanitization algorithm, so text that is merely
* incomplete never reaches the component at all the input hands it back as
* the empty string, which is a bound the operator cleared.
*/
import { useCallback, useState } from "react";
import { afterAll, expect, test, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
import { unixToDatetimeLocal } from "./datetime";
vi.stubEnv("TZ", "Europe/Paris");
afterAll(() => vi.unstubAllEnvs());
/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
const GAP_WALL_TIME = "2026-03-29T02:30:00";
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
/**
* The page's half of the contract: the applied state is held outside the form.
*
* `deferred` holds the patches back instead of applying them, so a test can
* land them in an order the network and the router can genuinely produce
* an early debounce arriving after a later one, over a draft that has moved on.
*/
function renderFilters(initial: AppliedFilters = NO_FILTERS, deferred = false) {
const patches: Array<Partial<AppliedFilters>> = [];
const state: { current: AppliedFilters } = { current: initial };
const setter: { current: ((next: AppliedFilters) => void) | null } = { current: null };
function Harness() {
const [applied, setApplied] = useState(initial);
state.current = applied;
setter.current = setApplied;
const onApply = useCallback((patch: Partial<AppliedFilters>) => {
patches.push(patch);
if (!deferred) setApplied((prev) => ({ ...prev, ...patch }));
}, []);
const onClear = useCallback(() => {
patches.push({});
if (!deferred) setApplied(NO_FILTERS);
}, []);
return <ActivityFilters applied={applied} onApply={onApply} onClear={onClear} />;
}
render(<Harness />);
/** The URL moving under the form: a commit landing, or the back button. */
function land(next: AppliedFilters) {
act(() => setter.current!(next));
}
return { patches, state, land };
}
function openTimeMenu() {
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
}
test("the segments commit to the applied state on the click, with no button in between", () => {
const { patches, state } = renderFilters();
expect(
screen.getAllByRole("radio").map((radio) => radio.closest("label")?.textContent),
).toEqual(["Any", "Blocked", "Allowed"]);
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
expect(patches).toEqual([{ blocked: true }]);
expect(state.current.blocked).toBe(true);
fireEvent.click(screen.getByRole("radio", { name: "Allowed" }));
expect(state.current.blocked).toBe(false);
fireEvent.click(screen.getByRole("radio", { name: "Any" }));
expect(state.current.blocked).toBeUndefined();
});
test("a time preset writes an absolute second, and the trigger names the preset", () => {
const now = 1_800_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const { patches } = renderFilters();
openTimeMenu();
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
"Any time",
"Past hour",
"Past 24 hours",
"Past 7 days",
"Custom…",
]);
fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));
// Both bounds, concrete: an open upper bound would keep taking in queries
// logged after the reader stopped looking, so the same link tomorrow would
// name a different day.
expect(patches).toEqual([{ since: now / 1000 - 86_400, until: now / 1000 }]);
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
// A clock that has moved on does not move the label: the URL still holds the
// second the click resolved to.
vi.spyOn(Date, "now").mockReturnValue(now + 60_000);
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
vi.restoreAllMocks();
});
test("bounds nobody picked here read as Custom, and Any time clears both", () => {
const { patches } = renderFilters({ ...NO_FILTERS, since: 1_700_000_000, until: 1_700_000_600 });
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Any time" }));
expect(patches).toEqual([{ since: undefined, until: undefined }]);
expect(screen.getByRole("button", { name: "Time: Any time" })).toBeTruthy();
});
test("the custom range applies only through Set range, and Enter is Set range", () => {
const { patches } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
const since = screen.getByLabelText("Since") as HTMLInputElement;
fireEvent.change(since, { target: { value: "2026-03-29T04:30:00" } });
// Typing a bound is not applying it: the other half may still be half-typed.
expect(patches).toEqual([]);
const applied = { since: Math.floor(new Date("2026-03-29T04:30:00").getTime() / 1000), until: undefined };
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(patches).toEqual([applied]);
// Enter inside a bound is that bound's action, not the toolbar's submit: the
// outer form flushes the text filters and would apply neither half of this.
fireEvent.change(since, { target: { value: "2026-03-29T05:30:00" } });
fireEvent.keyDown(since, { key: "Enter" });
expect(patches).toHaveLength(2);
expect(patches[1]).toEqual({
since: Math.floor(new Date("2026-03-29T05:30:00").getTime() / 1000),
until: undefined,
});
});
test("a rejected bound marks its own input and describes it", () => {
const { patches } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
const since = screen.getByLabelText("Since") as HTMLInputElement;
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(since.getAttribute("aria-invalid")).toBe("true");
expect(describedText(since)).toContain("daylight saving");
// Refused means refused, and only the offending bound carries the mark.
expect(patches).toEqual([]);
expect(until.getAttribute("aria-invalid")).toBeNull();
expect(until.getAttribute("aria-describedby")).toBeNull();
});
test("an upper bound below the lower one is refused, against the bound that is wrong", () => {
const { patches } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
const since = screen.getByLabelText("Since") as HTMLInputElement;
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(since, { target: { value: "2026-05-02T10:00:00" } });
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(until.getAttribute("aria-invalid")).toBe("true");
expect(describedText(until)).toContain("selects nothing");
expect(since.getAttribute("aria-invalid")).toBeNull();
expect(patches).toEqual([]);
// The window is half-open, so two equal bounds are as empty as an inverted
// pair and are refused the same way.
fireEvent.change(until, { target: { value: "2026-05-02T10:00:00" } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(until.getAttribute("aria-invalid")).toBe("true");
expect(patches).toEqual([]);
fireEvent.change(until, { target: { value: "2026-05-02T11:00:00" } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(until.getAttribute("aria-invalid")).toBeNull();
expect(patches).toHaveLength(1);
});
test("Clear keeps its place while there is nothing to clear, and stays out of the tab order", () => {
const { state } = renderFilters();
const clear = screen.getByText("Clear");
expect(clear.getAttribute("tabindex")).toBe("-1");
expect(clear.getAttribute("aria-hidden")).toBe("true");
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
expect(clear.getAttribute("tabindex")).toBeNull();
expect(clear.getAttribute("aria-hidden")).toBeNull();
fireEvent.click(clear);
expect(state.current).toEqual(NO_FILTERS);
expect(clear.getAttribute("tabindex")).toBe("-1");
});
test("Clear empties the text drafts along with the applied filters", () => {
vi.useFakeTimers();
try {
const { state } = renderFilters();
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
fireEvent.change(domain, { target: { value: "ads" } });
act(() => vi.advanceTimersByTime(400));
expect(state.current.domain).toBe("ads");
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
act(() => vi.advanceTimersByTime(400));
expect(domain.value).toBe("");
expect(state.current).toEqual(NO_FILTERS);
} finally {
vi.useRealTimers();
}
});
test("the domain field is search-shaped, unspellchecked, and labelled without a visible label", () => {
renderFilters();
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
expect(domain.type).toBe("search");
expect(domain.getAttribute("spellcheck")).toBe("false");
expect(domain.getAttribute("autocomplete")).toBe("off");
expect(domain.getAttribute("placeholder")).toBe("Filter domains…");
// The magnifier is decoration over the field, never a second thing to read.
expect(document.querySelector("svg[aria-hidden='true']")).toBeTruthy();
});
test("a bound change does not reset the domain draft that is still being typed", () => {
const now = 1_800_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const { state } = renderFilters();
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
fireEvent.change(domain, { target: { value: "ads" } });
// A preset commits at once, while the typed word is still waiting out its
// debounce. The URL moves, but not the part of it this field is derived from.
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
expect(state.current.since).toBe(now / 1000 - 3600);
expect(domain.value).toBe("ads");
vi.restoreAllMocks();
});
test("a debounced commit landing late does not roll the field back over newer typing", () => {
vi.useFakeTimers();
try {
const { patches, land } = renderFilters(NO_FILTERS, true);
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
// Two commits in flight, and the reader is a keystroke ahead of both.
fireEvent.change(domain, { target: { value: "ad" } });
act(() => vi.advanceTimersByTime(400));
fireEvent.change(domain, { target: { value: "ads" } });
act(() => vi.advanceTimersByTime(400));
expect(patches).toEqual([
{ domain: "ad", client: undefined },
{ domain: "ads", client: undefined },
]);
fireEvent.change(domain, { target: { value: "adsx" } });
// The older one lands first. It is this toolbar's own echo, two keystrokes
// stale, and seeding the field from it would delete what was typed since.
land({ ...NO_FILTERS, domain: "ad" });
expect(domain.value).toBe("adsx");
land({ ...NO_FILTERS, domain: "ads" });
expect(domain.value).toBe("adsx");
// An address that was never sent from here is someone else's — a pasted
// link, or the back button — and that one does move the field.
land({ ...NO_FILTERS, domain: "elsewhere" });
expect(domain.value).toBe("elsewhere");
} finally {
vi.useRealTimers();
}
});
test("a window moved from outside re-opens the custom row over the bounds it arrived with", () => {
const now = 1_800_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const { land } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
// A preset supersedes the custom row, so it is shut and the label is the preset.
expect(screen.queryByLabelText("Since")).toBeNull();
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
// The back button, or a pasted link: a range this form did not choose.
const since = 1_700_000_000;
land({ ...NO_FILTERS, since, until: since + 600 });
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
// The label says Custom, so the fields it names have to be on screen holding
// that range — a Custom window with nothing to read is the label lying.
// jsdom's value sanitizer spells the milliseconds out, so the seeded text is
// the prefix rather than the whole of what the input holds.
expect((screen.getByLabelText("Since") as HTMLInputElement).value).toContain(
unixToDatetimeLocal(since),
);
expect((screen.getByLabelText("Until") as HTMLInputElement).value).toContain(
unixToDatetimeLocal(since + 600),
);
vi.restoreAllMocks();
});
test("a window moved from outside clears an error left over from the old one", () => {
const { land } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(screen.getByLabelText("Since"), { target: { value: "2026-05-02T10:00:00" } });
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(until.getAttribute("aria-invalid")).toBe("true");
const since = 1_700_000_000;
land({ ...NO_FILTERS, since, until: since + 600 });
// The bounds the message was about are gone, so the message is too.
expect((screen.getByLabelText("Until") as HTMLInputElement).getAttribute("aria-invalid")).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
test("the client field names the exact match it performs", () => {
renderFilters();
const client = screen.getByLabelText("Client IP (exact match)") as HTMLInputElement;
expect(client.getAttribute("placeholder")).toBe("Client IP…");
});
test("an earlier range commit landing late does not unseat the pick that is current", () => {
const first = 1_800_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(first);
const { patches, land } = renderFilters(NO_FILTERS, true);
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
// A second pick before the first has come back through the URL.
const second = first + 60_000;
vi.spyOn(Date, "now").mockReturnValue(second);
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));
expect(patches).toEqual([
{ since: first / 1000 - 3600, until: first / 1000 },
{ since: second / 1000 - 86_400, until: second / 1000 },
]);
// The older window lands first. It is this toolbar's own echo, so it must not
// read as a range someone else chose: doing that would clear the preset and
// throw the custom row open over a window the reader has already moved past.
land({ ...NO_FILTERS, since: first / 1000 - 3600, until: first / 1000 });
expect(screen.queryByLabelText("Since")).toBeNull();
land({ ...NO_FILTERS, since: second / 1000 - 86_400, until: second / 1000 });
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
expect(screen.queryByLabelText("Since")).toBeNull();
vi.restoreAllMocks();
});
+550 -140
View File
@@ -1,73 +1,198 @@
/**
* The filter row over the Activity table.
* The filter toolbar over the Activity history 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.
* the link they can paste to a housemate will show. So this holds a draft, and
* every control writes through to the URL the segments and the time presets
* at once, the two text fields after a pause so a five-letter domain is one
* navigation rather than five.
*
* 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
* A time preset writes both bounds as absolute seconds, resolved once at the
* click. Neither half may be left open: a bookmark has to describe the same
* investigation tomorrow, and a window that slid overnight or one that stayed
* open at the top and swallowed everything logged since would answer a
* different question under the same link.
*
* A URL can move under this form at any time, and the draft only follows the
* part of it that actually moved. A field is re-seeded when its own URL value
* changed and the new value is not one this toolbar just wrote: the debounce
* means the URL is always a little behind the keyboard, and a landing commit
* must not roll the input back over the letters typed since.
*
* The custom range is the one control that does not live-apply. Two half-typed
* timestamps are a normal intermediate state of typing one of them, and a lower
* bound at or above an upper bound selects nothing at all, so the pair is
* validated and applied together or not at all.
*
* Live mode does not render this at all the page mounts it inside the History
* panel. The stream is unfiltered, and a row of controls that looked usable
* would promise filtering that is not happening.
*/
import { useState, type FormEvent } from "react";
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import Select from "@/ui/Select";
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
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" },
];
/** Long enough that a typed word is one navigation, short enough to feel live. */
const DEBOUNCE_MS = 350;
const RESULTS = [
{ value: "any", label: "Any" },
{ value: "blocked", label: "Blocked" },
{ value: "allowed", label: "Allowed" },
] as const;
const PRESETS = [
{ label: "Any time", seconds: null },
{ label: "Past hour", seconds: 3600 },
{ label: "Past 24 hours", seconds: 86_400 },
{ label: "Past 7 days", seconds: 604_800 },
] as const;
const CUSTOM_ITEM = "Custom…";
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
const HIT_TARGET = 44;
const styles = stylex.create({
/** One column on a phone, two from `sm`, five from `lg`. */
grid: {
toolbar: {
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))",
},
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
label: {
display: "block",
/** The domain field is the one that grows; everything else keeps its size. */
searchWrap: {
position: "relative",
flexGrow: 1,
flexShrink: 1,
flexBasis: "14rem",
display: "flex",
},
searchIcon: {
position: "absolute",
insetInlineStart: "0.5rem",
top: "50%",
transform: "translateY(-50%)",
color: colors.textMuted,
pointerEvents: "none",
},
/** Every control in the row is a pointer target before it is anything else. */
field: {
minHeight: HIT_TARGET,
},
searchInput: {
width: "100%",
paddingInlineStart: "1.875rem",
},
clientInput: {
flexGrow: 0,
flexShrink: 1,
flexBasis: "10rem",
},
/** A button is text-sized by default; this is the hit area around the text. */
hitTarget: {
minHeight: HIT_TARGET,
minWidth: HIT_TARGET,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
},
resultGroup: {
display: "flex",
gap: "0.25rem",
},
/** The segment styling of the Overview period picker, item for item. */
segment: {
cursor: "pointer",
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
minHeight: HIT_TARGET,
minWidth: HIT_TARGET,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
},
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
segmentFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
segmentSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
segmentIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
popover: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
color: colors.text,
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menu: {
outlineStyle: "none",
paddingBlock: "0.25rem",
},
menuItem: {
cursor: "pointer",
paddingInline: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
whiteSpace: "nowrap",
minHeight: HIT_TARGET,
display: "flex",
alignItems: "center",
},
/** Inset because an item flush against the popover edge clips an outset ring. */
menuItemFocused: {
backgroundColor: colors.primary,
color: colors.primaryText,
outlineColor: { default: null, ":focus-visible": colors.primaryText },
},
/**
* Clear keeps its box when there is nothing to clear. It appears the moment a
* filter is set, and a control that appeared by widening the row would move
* every other control out from under the pointer that was reaching for it.
*/
clearHidden: {
visibility: "hidden",
},
customRow: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
alignItems: "flex-end",
gap: "0.5rem",
},
customField: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
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,
@@ -86,6 +211,22 @@ export const NO_FILTERS: AppliedFilters = {
until: undefined,
};
/** The half-open window the API reads: `ts >= since` and `ts < until`. */
interface Bounds {
since: number | undefined;
until: number | undefined;
}
/** The two text filters as the URL spells them, with absent written as empty. */
interface TextPair {
domain: string;
client: string;
}
function sameBounds(a: Bounds, b: Bounds): boolean {
return a.since === b.since && a.until === b.until;
}
function blockedOption(blocked: boolean | undefined): string {
if (blocked === undefined) return "any";
return blocked ? "blocked" : "allowed";
@@ -96,135 +237,404 @@ function optionBlocked(value: string): boolean | undefined {
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.`;
/** A filter value the URL can carry: trimmed, and empty means absent. */
function textFilter(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/** The message plus the bound it belongs to, so that input can point at it. */
interface BoundError {
field: "since" | "until";
message: string;
}
/** The toolbar is rendered once per page, so the message can hold a fixed id. */
const ERROR_ID = "activity-filter-error";
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
const label = field === "since" ? "Since" : "Until";
return {
field,
message:
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.`,
};
}
/** The preset a label is claimed for, kept only while the URL still holds it. */
interface ChosenPreset extends Bounds {
label: string;
}
/**
* What this toolbar knows about the URL, and what it is still waiting to see
* come back from it.
*
* `url` and `bounds` are the last values looked at, so a change can be told
* apart field by field. `pendingText` and `pendingBounds` are every commit made
* here that the URL has not echoed yet. Both are queues rather than single
* slots: two keystrokes either side of the debounce put two text commits in
* flight, and two menu picks in quick succession do the same to the range. In
* both cases the older echo landing second must not be mistaken for someone
* else's edit that is what would clear the preset out from under the pick
* that is actually current.
*/
interface Sync {
url: TextPair;
bounds: Bounds;
pendingText: TextPair[];
pendingBounds: Bounds[];
}
interface Props {
applied: AppliedFilters;
isDisabled: boolean;
onApply: (filters: AppliedFilters) => void;
/** Merges a patch into the applied search. `replace` keeps typing out of history. */
onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => 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));
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
const urlText: TextPair = { domain: applied.domain ?? "", client: applied.client ?? "" };
const urlBounds: Bounds = { since: applied.since, until: applied.until };
const [domain, setDomain] = useState(urlText.domain);
const [client, setClient] = useState(urlText.client);
const [preset, setPreset] = useState<ChosenPreset | null>(null);
const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<BoundError | null>(null);
const [sync, setSync] = useState<Sync>({
url: urlText,
bounds: urlBounds,
pendingText: [],
pendingBounds: [],
});
function submit(event: FormEvent) {
const textMoved = sync.url.domain !== urlText.domain || sync.url.client !== urlText.client;
const boundsMoved = !sameBounds(sync.bounds, urlBounds);
if (textMoved || boundsMoved) {
let pendingText = sync.pendingText;
let pendingBounds = sync.pendingBounds;
if (textMoved) {
// The newest commit the URL matches, and everything before it, has now
// been accounted for; an older one landing later is not new news.
const echo = pendingText.findLastIndex(
(sent) => sent.domain === urlText.domain && sent.client === urlText.client,
);
if (echo >= 0) {
pendingText = pendingText.slice(echo + 1);
} else {
pendingText = [];
if (sync.url.domain !== urlText.domain) setDomain(urlText.domain);
if (sync.url.client !== urlText.client) setClient(urlText.client);
}
}
if (boundsMoved) {
const echo = pendingBounds.findLastIndex((sent) => sameBounds(sent, urlBounds));
if (echo >= 0) {
pendingBounds = pendingBounds.slice(echo + 1);
} else {
// Someone else moved the window — the back button, or a pasted link.
// Whatever this form was showing about the old one is now wrong: the
// preset it was named after, an error against bounds that are gone,
// and a custom row that is open or shut for the wrong range.
pendingBounds = [];
setPreset(null);
setError(null);
setShowCustom(urlBounds.since !== undefined || urlBounds.until !== undefined);
setSince(datetimeField(urlBounds.since));
setUntil(datetimeField(urlBounds.until));
}
}
setSync({ url: urlText, bounds: urlBounds, pendingText, pendingBounds });
}
const dirty = textFilter(domain) !== applied.domain || textFilter(client) !== applied.client;
const commitText = useCallback(
(replace: boolean) => {
const next = { domain: textFilter(domain), client: textFilter(client) };
setSync((prev) => ({
...prev,
pendingText: [...prev.pendingText, { domain: next.domain ?? "", client: next.client ?? "" }],
}));
onApply(next, replace);
},
[domain, client, onApply],
);
function commitBounds(bounds: Bounds) {
setSync((prev) => ({ ...prev, pendingBounds: [...prev.pendingBounds, bounds] }));
onApply(bounds);
}
useEffect(() => {
if (!dirty) return;
const id = setTimeout(() => commitText(true), DEBOUNCE_MS);
return () => clearTimeout(id);
}, [dirty, commitText]);
function flush(event: FormEvent) {
event.preventDefault();
if (dirty) commitText(true);
}
function selectPreset(label: string) {
if (label === CUSTOM_ITEM) {
setShowCustom(true);
return;
}
const option = PRESETS.find((candidate) => candidate.label === label);
if (option === undefined) return;
setShowCustom(false);
setError(null);
if (option.seconds === null) {
setPreset(null);
setSince(datetimeField(undefined));
setUntil(datetimeField(undefined));
commitBounds({ since: undefined, until: undefined });
return;
}
// Both bounds, resolved once, here. An open upper bound would keep taking
// in queries logged after the reader stopped looking, so "the past hour"
// would name a different hour every time the link was opened.
const now = Math.floor(Date.now() / 1000);
const bounds: Bounds = { since: now - option.seconds, until: now };
setPreset({ label, ...bounds });
setSince(datetimeField(bounds.since));
setUntil(datetimeField(bounds.until));
commitBounds(bounds);
}
function setRange() {
const sinceValue = resolveDatetimeField(since);
if (!sinceValue.ok) {
setError(boundError("Since", sinceValue.reason));
setError(boundError("since", sinceValue.reason));
return;
}
const untilValue = resolveDatetimeField(until);
if (!untilValue.ok) {
setError(boundError("Until", untilValue.reason));
setError(boundError("until", untilValue.reason));
return;
}
// The window is half-open — `ts >= since` and `ts < until` — so two equal
// bounds are as empty as an inverted pair, and neither is worth applying.
if (
sinceValue.value !== undefined &&
untilValue.value !== undefined &&
untilValue.value <= sinceValue.value
) {
setError({
field: "until",
message: "Until must be after Since, or the range selects nothing.",
});
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,
});
setPreset(null);
commitBounds({ since: sinceValue.value, until: untilValue.value });
}
function clear() {
setDomain("");
setClient("");
setBlocked("any");
setPreset(null);
setShowCustom(false);
setSince(datetimeField(undefined));
setUntil(datetimeField(undefined));
setError(null);
setSync((prev) => ({
...prev,
pendingText: [],
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
}));
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}
const active =
domain.trim() !== "" ||
client.trim() !== "" ||
applied.blocked !== undefined ||
applied.since !== undefined ||
applied.until !== undefined;
// The label the URL earns on its own, overridden only while a preset click is
// still the whole of what the URL says. Bounds nobody here chose read as
// "Custom": that is what a pasted link or an edited range is.
let timeLabel = "Any time";
if (applied.since !== undefined || applied.until !== undefined) {
timeLabel = preset !== null && sameBounds(preset, urlBounds) ? preset.label : "Custom";
}
/** True for the one bound the current message is about; nothing else is marked. */
const invalid = (field: BoundError["field"]): true | undefined =>
error !== null && error.field === field ? true : undefined;
function boundInput(field: BoundError["field"]) {
const state = field === "since" ? since : until;
const set = field === "since" ? setSince : setUntil;
return (
<label {...stylex.props(styles.customField)}>
{field === "since" ? "Since" : "Until"}
<input
type="datetime-local"
step={1}
value={state.text}
aria-invalid={invalid(field)}
aria-describedby={invalid(field) && ERROR_ID}
onChange={(event) => set(editDatetimeField(state, event.target.value))}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
// Enter here means this range, not the toolbar's text filters:
// the two bounds only ever apply together, and the outer form's
// submit would apply neither of them.
if (event.key !== "Enter") return;
event.preventDefault();
setRange();
}}
onBlur={() => {
const resolved = resolveDatetimeField(state);
if (!resolved.ok) setError(boundError(field, resolved.reason));
else if (invalid(field)) setError(null);
}}
{...stylex.props(shared.smallInput, styles.field, shared.focusRing)}
/>
<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)}
{invalid(field) && (
<span id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
{error?.message}
</span>
)}
</label>
);
}
return (
<form onSubmit={flush}>
<div {...stylex.props(styles.toolbar)}>
<div {...stylex.props(styles.searchWrap)}>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
{...stylex.props(styles.searchIcon)}
>
Apply filters
</button>
<circle cx="7" cy="7" r="4.5" />
<path d="M10.5 10.5 14 14" strokeLinecap="round" />
</svg>
<input
type="search"
aria-label="Filter domains"
placeholder="Filter domains…"
spellCheck={false}
autoComplete="off"
value={domain}
onChange={(event) => setDomain(event.target.value)}
{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
/>
</div>
{/*
* The client filter matches one address exactly the server has no
* substring match for it so the name says so rather than leaving the
* reader to discover it by typing half an address and getting nothing.
*/}
<input
type="text"
aria-label="Client IP (exact match)"
placeholder="Client IP…"
spellCheck={false}
autoComplete="off"
value={client}
onChange={(event) => setClient(event.target.value)}
{...stylex.props(shared.smallInput, styles.field, styles.clientInput, shared.focusRing)}
/>
<RadioGroup
aria-label="Result"
orientation="horizontal"
value={blockedOption(applied.blocked)}
onChange={(next) => onApply({ blocked: optionBlocked(next) })}
className={() => stylex.props(styles.resultGroup).className ?? ""}
>
{RESULTS.map((option) => (
<Radio
key={option.value}
value={option.value}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
styles.segment,
isSelected ? styles.segmentSelected : styles.segmentIdle,
isFocusVisible && styles.segmentFocusVisible,
).className ?? ""
}
>
{option.label}
</Radio>
))}
</RadioGroup>
<MenuTrigger>
<Button
className={() =>
stylex.props(shared.button, styles.hitTarget, shared.focusRing).className ?? ""
}
>
Time: {timeLabel}
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<Menu {...stylex.props(styles.menu)}>
{[...PRESETS.map((option) => option.label), CUSTOM_ITEM].map((label) => (
<MenuItem
key={label}
onAction={() => selectPreset(label)}
className={({ isFocused }) =>
stylex.props(
styles.menuItem,
shared.insetFocusRing,
isFocused && styles.menuItemFocused,
).className ?? ""
}
>
{label}
</MenuItem>
))}
</Menu>
</Popover>
</MenuTrigger>
<button
type="button"
onClick={clear}
tabIndex={active ? undefined : -1}
aria-hidden={active ? undefined : true}
{...stylex.props(
shared.button,
styles.hitTarget,
shared.focusRing,
!active && styles.clearHidden,
)}
>
Clear
</button>
</div>
{showCustom && (
<div {...stylex.props(styles.customRow)}>
{boundInput("since")}
{boundInput("until")}
<button
type="button"
onClick={clear}
disabled={isDisabled}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
onClick={setRange}
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
>
Clear
Set range
</button>
</div>
</form>
{error !== null && (
<p role="alert" {...stylex.props(styles.error)}>
{error}
</p>
)}
</>
</form>
);
}
+214 -23
View File
@@ -9,8 +9,10 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/queries/provenanceFixture";
import { queryRow } from "@/features/provenance/provenanceFixture";
import { FakeEventSource } from "./fakeEventSource";
function client(id: number, ip: string, name: string, learnedName: string): Client {
return {
@@ -91,6 +93,8 @@ function stubFetch(handler: (url: string) => Response | Promise<Response>) {
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
@@ -132,6 +136,22 @@ function queryCalls(): string[] {
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
}
/** The toolbar's search field, which is how a domain filter is entered now. */
function domainInput(): HTMLInputElement {
return screen.getByLabelText("Filter domains") as HTMLInputElement;
}
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
function submitFilters() {
fireEvent.submit(domainInput().closest("form")!);
}
/** The custom range lives behind the Time menu; the two bounds only exist there. */
function openCustomRange() {
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
}
test("renders the first page with the seven columns filled in", async () => {
renderPage();
await screen.findByText("first.example");
@@ -159,7 +179,7 @@ test("renders the first page with the seven columns filled in", async () => {
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
});
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
test("resolves each row's client to its display name, reading the IP out with it", 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 });
@@ -179,19 +199,22 @@ test("resolves each row's client to its display name, keeping the IP as the tool
// 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");
// The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
expect(named.getAttribute("title")).toBeNull();
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(learned.textContent).toBe("laptop.lan (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();
// seen, both fall back to the bare address with nothing standing in for it.
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
});
test("load more appends the next page and stops at the end of the log", async () => {
@@ -213,8 +236,8 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
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" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await screen.findByText(/Showing 1 query /);
expect(history.location.search).toContain("domain=ads");
@@ -239,8 +262,8 @@ test("a load-more that resolves after a filter change is discarded", async () =>
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" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await screen.findByText(/Showing 1 query /);
releaseLoadMore();
@@ -278,8 +301,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
renderPage();
await screen.findByText("first.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
const staleButton = await screen.findByRole("button", { name: "Load more" });
expect(staleButton).toHaveProperty("disabled", true);
@@ -413,7 +436,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
expect(domainInput()).toHaveProperty("value", "ads");
expect(screen.queryByText("first.example")).toBeNull();
});
@@ -438,7 +461,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
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", "");
expect(domainInput()).toHaveProperty("value", "");
});
test("the form draft follows the url back and forward, seconds included", async () => {
@@ -451,25 +474,33 @@ test("the form draft follows the url back and forward, seconds included", async
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");
await screen.findByLabelText("Filter domains");
expect(domainInput()).toHaveProperty("value", "first");
// A seeded custom range opens its row, so the link's bounds are visible.
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" }));
fireEvent.change(domainInput(), { target: { value: "second" } });
submitFilters();
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}`);
// A pasted link, then the buttons over it: the draft is derived from the URL,
// so whichever way the browser moves it the field has to move with it.
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
await waitFor(() => {
expect(domainInput()).toHaveProperty("value", "third");
});
act(() => history.back());
await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
expect(domainInput()).toHaveProperty("value", "second");
});
act(() => history.forward());
await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
expect(domainInput()).toHaveProperty("value", "third");
});
});
@@ -505,8 +536,9 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
const callsBefore = queryCalls().length;
const searchBefore = history.location.search;
openCustomRange();
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
if (inGap) {
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
@@ -541,6 +573,40 @@ test("the policy simulation is reachable from the header, with no rows to click
expect(history.location.pathname).toBe("/activity/test");
});
test("the mode switch is a tab list whose selection is the url, and it keeps the filters", async () => {
// Live opens a stream as soon as its panel mounts, so the switch cannot be
// exercised without one; the fake stands in for the browser's EventSource.
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
return new FakeEventSource(url) as unknown as EventSource;
}
},
);
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
await screen.findByText("ads.example");
const tabs = within(screen.getByRole("tablist", { name: "Activity mode" }));
expect(tabs.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["History", "Live"]);
expect(tabs.getByRole("tab", { name: "History", selected: true })).toBeTruthy();
expect(tabs.getByRole("tab", { name: "Live", selected: false })).toBeTruthy();
fireEvent.click(tabs.getByRole("tab", { name: "Live" }));
await waitFor(() => expect(history.location.search).toContain("mode=live"));
// The investigation survives the switch: both filters are still in the URL.
expect(history.location.search).toContain("domain=ads");
expect(history.location.search).toContain("blocked=true");
expect(
within(screen.getByRole("tablist", { name: "Activity mode" })).getByRole("tab", {
name: "Live",
selected: true,
}),
).toBeTruthy();
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
});
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");
@@ -550,5 +616,130 @@ test("Clear empties the url as well as the form", async () => {
expect(history.location.search).not.toContain("domain");
});
expect(history.location.search).not.toContain("blocked");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
expect(domainInput()).toHaveProperty("value", "");
});
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { history } = renderPage();
await screen.findByText("first.example");
fireEvent.change(domainInput(), { target: { value: "a" } });
fireEvent.change(domainInput(), { target: { value: "ad" } });
fireEvent.change(domainInput(), { target: { value: "ads" } });
// Mid-word the URL has not moved: three keystrokes are one investigation,
// not three, and each one would otherwise be a request and a history entry.
expect(history.location.search).not.toContain("domain");
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(history.location.search).toContain("domain=ads");
// Replaced, not pushed: Back leaves the page, it does not retype the word.
expect(history.length).toBe(1);
fireEvent.change(domainInput(), { target: { value: "first" } });
submitFilters();
await waitFor(() => expect(history.location.search).toContain("domain=first"));
} finally {
vi.useRealTimers();
}
});
test("the field being typed in keeps the focus when the debounce commits", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { history } = renderPage();
await screen.findByText("first.example");
const input = domainInput();
input.focus();
fireEvent.change(input, { target: { value: "ads" } });
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
await waitFor(() => expect(history.location.search).toContain("domain=ads"));
// The same node, still focused, still holding the caret: a toolbar that
// remounted on the URL it just wrote would drop the next keystroke.
expect(domainInput()).toBe(input);
expect(document.activeElement).toBe(input);
} finally {
vi.useRealTimers();
}
});
test("a result segment commits on the click, with no wait and no button", async () => {
const { history } = renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
await waitFor(() => expect(history.location.search).toContain("blocked=true"));
expect(history.location.search).toContain("domain=ads");
});
test("a time preset writes the second it resolved to, not a rolling window", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
const { history } = renderPage();
await screen.findByText("No queries logged yet.");
fireEvent.click(screen.getByRole("button", { name: "Time: Any time" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
// Both bounds are concrete, so the link names a closed hour rather than one
// that keeps growing at the top as the log does.
const since = now / 1000 - 3600;
const until = now / 1000;
await waitFor(() => expect(history.location.search).toContain(`since=${since}`));
expect(history.location.search).toContain(`until=${until}`);
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
expect(queryCalls()).toContain(`/api/queries?since=${since}&until=${until}`);
vi.restoreAllMocks();
});
test("Live shows no toolbar at all", async () => {
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
return new FakeEventSource(url) as unknown as EventSource;
}
},
);
renderPage("/activity?mode=live&domain=ads");
await screen.findByText(/the History filters apply to history only/);
// The stream is unfiltered, so a control here would promise filtering that is
// not happening; the filters are still in the URL, waiting for History.
expect(screen.queryByLabelText("Filter domains")).toBeNull();
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
});
test("the coverage watermark reads under the results, never over them", 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");
const watermark = screen.getByText(/Query history is available from/);
const count = screen.getByText(/Showing 1 query/);
// After the count in document order, which is what "footer" means here.
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
+89 -47
View File
@@ -8,8 +8,10 @@
* recipient should be looking at.
*/
import { useCallback } from "react";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
@@ -53,6 +55,13 @@ const styles = stylex.create({
fontWeight: 500,
cursor: "pointer",
},
/** A Tab is a `div` with a roving tabindex, so RAC drives the ring, not `:focus-visible`. */
modeFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
modeIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: { default: colors.textSecondary, ":hover": colors.text },
@@ -76,13 +85,33 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
panel: {
outlineStyle: "none",
},
/**
* A panel with nothing tabbable in it an empty or loading history is given
* a tabindex by RAC so the reader can still reach its content, so it has to be
* able to show that it holds the focus.
*/
panelFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
tabsRoot: {
display: "block",
},
});
function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
return stylex.props(styles.panel, isFocusVisible && styles.panelFocusVisible).className ?? "";
}
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.
@@ -91,62 +120,75 @@ export default function ActivityPage() {
void navigate({ search: (prev) => ({ ...prev, mode }) });
}
function apply(filters: AppliedFilters) {
void navigate({ search: { mode: search.mode, ...filters } });
}
// A patch, merged into whatever the URL already says: a segment click must not
// spell out the four filters it is not about. `replace` is the debounced text
// commits, so the back button steps between investigations, not keystrokes.
const apply = useCallback(
(patch: Partial<AppliedFilters>, replace = false) => {
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
},
[navigate],
);
const clear = useCallback(() => {
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
}, [navigate]);
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
{/*
* The selected tab is the URL's `mode` and nothing else. RAC would hold
* the selection itself, but a second copy of it would fight the back
* button, so the search parameter stays the only state there is.
*/}
<Tabs
selectedKey={search.mode}
onSelectionChange={(key) => selectMode(key as ActivityMode)}
className={() => stylex.props(styles.tabsRoot).className ?? ""}
>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Activity</h1>
<TabList
aria-label="Activity mode"
className={() => stylex.props(styles.switch).className ?? ""}
>
{MODES.map((option) => (
<Tab
key={option.mode}
type="button"
aria-pressed={selected}
onClick={() => selectMode(option.mode)}
{...stylex.props(
styles.modeButton,
selected ? styles.modeSelected : styles.modeIdle,
shared.focusRing,
)}
id={option.mode}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
styles.modeButton,
isSelected ? styles.modeSelected : styles.modeIdle,
isFocusVisible && styles.modeFocusVisible,
).className ?? ""
}
>
{option.label}
</button>
);
})}
</Tab>
))}
</TabList>
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
Current policy simulation
</Link>
</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 ? (
<>
{/*
* The toolbar belongs to History alone. It is not remounted on a search
* change: it resyncs its draft from the URL instead, because a remount
* mid-debounce would take the focus out of the input being typed in.
*/}
<TabPanel id="history" className={panelClass}>
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
<HistoryActivity search={search} />
</TabPanel>
<TabPanel id="live" className={panelClass}>
<p {...stylex.props(styles.liveNote)}>
The stream carries every query the server answers; these filters apply to history only.
The stream carries every query the server answers; the History filters apply to history only.
</p>
<LiveActivity origin={search} />
</>
) : (
<HistoryActivity search={search} />
)}
</TabPanel>
</Tabs>
</section>
);
}
@@ -15,7 +15,7 @@ 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 { summarizeRow } from "@/features/provenance/querySummary";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
@@ -56,7 +56,8 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
refetching: {
/** The gap a line standing on its own needs from the block above it. */
spacedTop: {
marginTop: "0.75rem",
},
moreButton: {
@@ -81,7 +82,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
const pages = base.data?.pages ?? [];
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
const coverage = pages[0]?.coverage;
// Only from the settled response. Placeholder pages belong to the previous
// filter, and a watermark is a claim about the window being displayed.
const coverage = base.isPlaceholderData ? undefined : 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
@@ -112,16 +115,27 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
return (
<>
{/*
* A quiet line, never a skeleton: the rows on screen stay put while a
* filter change is in flight, so a keystroke must not blank the table
* it is narrowing.
*/}
{base.isFetching && (
<p {...stylex.props(styles.note, styles.refetching)} role="status">
Loading
<p {...stylex.props(styles.note, styles.spacedTop)} role="status">
Updating
</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>
<>
<p {...stylex.props(styles.empty)}>
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p>
{coverage !== undefined && (
<div {...stylex.props(styles.spacedTop)}>
<CoverageNotice coverage={coverage} variant="note" />
</div>
)}
</>
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
@@ -158,6 +172,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"}
</p>
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
{hasMore && (
<button
type="button"
+277 -89
View File
@@ -6,15 +6,27 @@
* which subtree the URL mounts, not by a prop a caller could pass.
*/
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { RouterContextProvider, 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 { provenance, queryRow } from "@/features/provenance/provenanceFixture";
import { health } from "@/lib/healthFixture";
import { FakeEventSource } from "./fakeEventSource";
import LiveActivity from "./LiveActivity";
import type { ActivitySearch } from "./search";
const LIVE_ORIGIN: ActivitySearch = {
mode: "live",
since: undefined,
until: undefined,
domain: undefined,
client: undefined,
blocked: undefined,
};
function client(ip: string, name: string, learnedName: string): Client {
return {
@@ -45,11 +57,23 @@ 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({})) {
/**
* The empty history page. Live mode asks for no query pages, but switching back
* to History does, and a page-shaped response is the only honest answer there.
*/
const EMPTY_PAGE = { queries: [], next_before: null, coverage: { complete: true, available_since: 0 } };
function defaultHandler(url: string): Response {
return json(url === "/api/queries" || url.startsWith("/api/queries?") ? EMPTY_PAGE : {});
}
function stubFetch(handler: (url: string) => Response | Promise<Response> = defaultHandler) {
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 }));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return Promise.resolve(handler(url));
});
vi.stubGlobal("fetch", fetchMock);
@@ -154,7 +178,7 @@ test("streams rows, flags blocked ones, and freezes the display", async () => {
expect(screen.getByText("later.example")).toBeTruthy();
});
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
test("resolves each row's client to its display name, reading the IP out with it", async () => {
await openLive();
act(() => {
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
@@ -164,11 +188,14 @@ test("resolves each row's client to its display name, keeping the IP as the tool
});
const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10");
// The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
expect(named.getAttribute("title")).toBeNull();
expect(screen.queryByText("pi.lan")).toBeNull();
const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
@@ -180,6 +207,8 @@ test("rows stream in as bare IPs while the client list is still loading", async
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
// The shell reads health on every route for the Diagnostics nav badge.
if (url === "/api/health") return Promise.resolve(json(health()));
return new Promise<Response>((resolve) => {
if (url !== "/api/clients") {
resolve(json({}));
@@ -240,7 +269,40 @@ test("a recovered row links to its stored detail; a streamed one opens in place
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 () => {
/** The open detail. Named by its heading, so the query proves the name is visible. */
function detailDialog(): HTMLElement {
return screen.getByRole("dialog", { name: "Streamed query" });
}
/** React Aria's ModalOverlay, two levels out from the dialog it wraps. */
function backdrop(): HTMLElement {
return detailDialog().parentElement!.parentElement!;
}
/**
* Activate a control the way a keyboard does. jsdom runs no default action for
* Enter on a button, so the click a browser would then dispatch is issued here;
* `detail: 0` is what marks it as keyboard-driven rather than pointer-driven,
* and is the flag React Aria itself reads.
*/
function pressWithKeyboard(control: HTMLElement) {
act(() => control.focus());
fireEvent.keyDown(control, { key: "Enter" });
fireEvent.click(control, { detail: 0 });
fireEvent.keyUp(control, { key: "Enter" });
}
/** Activate a control the way a mouse does, through the full pointer sequence. */
function pressWithMouse(control: HTMLElement) {
fireEvent.pointerDown(control, { pointerType: "mouse", button: 0 });
fireEvent.pointerUp(control, { pointerType: "mouse", button: 0 });
fireEvent.click(control, { detail: 1 });
}
test.each([
["the pointer", pressWithMouse],
["the keyboard", pressWithKeyboard],
])("a streamed row opens its provenance in a named dialog, from %s", async (_label, press) => {
await openLive();
act(() =>
sources[0]!.emit(
@@ -257,120 +319,220 @@ test("a streamed row opens its own provenance, from the keyboard as well as 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);
// The row opens a dialog, so it says so; what it no longer claims is to
// expand a region that stays in the page.
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
expect(trigger.getAttribute("aria-expanded")).toBeNull();
expect(trigger.getAttribute("aria-controls")).toBeNull();
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");
press(trigger);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
const dialog = detailDialog();
expect(within(dialog).getByRole("heading", { level: 1, name: "streamed.example" })).toBeTruthy();
expect(within(dialog).getByText("Blocked locally")).toBeTruthy();
expect(dialog.textContent).toContain("the query log may not have written it yet");
// React Aria may defer the move by a frame, depending on the modality it read
// from the activation, so the wait is the assertion rather than a workaround.
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
expect(within(dialog).getByRole("button", { name: "Close" })).toBeTruthy();
});
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
test("tabbing forward and backward stays inside the open dialog", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
const dialog = detailDialog();
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
// The related links land asynchronously; tabbing before them would walk a
// shorter dialog than the reader ever sees.
await waitFor(() => expect(within(dialog).getAllByRole("link").length).toBeGreaterThan(1));
const visited = new Set<Element>();
for (const shiftKey of [false, false, false, false, false, false, true, true, true, true]) {
fireEvent.keyDown(document.activeElement!, { key: "Tab", shiftKey });
fireEvent.keyUp(document.activeElement!, { key: "Tab", shiftKey });
expect(dialog.contains(document.activeElement)).toBe(true);
visited.add(document.activeElement!);
}
// Containment that never moved focus would satisfy the check above without
// trapping anything, so the walk has to have actually walked.
expect(visited.size).toBeGreaterThan(1);
});
test.each([
["the Close button", () => fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }))],
["Escape", () => fireEvent.keyDown(detailDialog(), { key: "Escape" })],
[
"a click on the backdrop",
() => {
const overlay = backdrop();
fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 });
fireEvent.pointerUp(overlay, { pointerType: "mouse", button: 0 });
fireEvent.click(overlay, { detail: 1 });
},
],
])("%s closes the dialog and returns focus to the row that opened it", async (_label, dismiss) => {
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);
expect(detailDialog()).toBeTruthy();
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);
dismiss();
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();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
});
test("opening a second row moves the expanded state and the focus with it", async () => {
test("the stream runs on behind the open dialog, and its rows land in the table on close", async () => {
await openLive();
act(() => {
sources[0]!.emit("query", frame(1000, "first.example"));
sources[0]!.emit("query", frame(1001, "second.example"));
});
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
const first = screen.getByRole("button", { name: "first.example" });
const second = screen.getByRole("button", { name: "second.example" });
fireEvent.click(first);
fireEvent.click(second);
const snapshot = detailDialog().textContent;
act(() => sources[0]!.emit("query", frame(1001, "arrived-while-open.example")));
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");
// The connection is untouched: no close, no second EventSource.
expect(sources).toHaveLength(1);
expect(sources[0]!.closed).toBe(false);
// And the snapshot is a snapshot: nothing that arrives rewrites it.
expect(detailDialog().textContent).toBe(snapshot);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(document.activeElement).toBe(second);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getByRole("button", { name: "arrived-while-open.example" })).toBeTruthy();
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
});
test("an open streamed detail survives the row being evicted from the ring buffer", async () => {
test("the rows and toolbar behind the dialog are out of reach while it is open", 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();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
// 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.
// React Aria hides everything outside the modal from assistive technology
// and from the pointer alike, so the row and the toolbar are unreachable by
// role: nothing behind the dialog can be operated while it is open.
expect(screen.queryByRole("button", { name: "streamed.example" })).toBeNull();
expect(screen.queryByRole("button", { name: "Freeze" })).toBeNull();
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getByRole("button", { name: "Freeze" })).toBeTruthy();
});
/**
* The one render that bypasses the route, because the ring capacity is a
* parameter of the component and the route deliberately never passes it:
* evicting a row at the real 500 means pushing 500 frames through React state,
* which proves nothing the fifth frame does not. The real route tree still
* backs the links inside the detail.
*/
function renderLiveWithCapacity(capacity: number) {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/activity?mode=live"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterContextProvider router={router}>
<LiveActivity origin={LIVE_ORIGIN} capacity={capacity} />
</RouterContextProvider>
</QueryClientProvider>
</AuthProvider>,
);
}
/** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
function evictWithFiller() {
act(() => {
for (let index = 0; index < 500; index += 1) {
for (let index = 0; index < 5; index += 1) {
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
}
});
}
test("an open dialog's snapshot survives its row being evicted from the ring buffer", async () => {
renderLiveWithCapacity(5);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
const snapshot = detailDialog().textContent;
expect(within(detailDialog()).getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
// One ringful more: the ring keeps the newest 5, so the selected row is gone.
// The dialog holds the frame itself, not a lookup into the ring, so it neither
// blanks out nor closes.
evictWithFiller();
expect(detailDialog().textContent).toBe(snapshot);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getAllByRole("row")).toHaveLength(6);
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
});
test("closing after the source row is evicted anchors focus in the results region", async () => {
renderLiveWithCapacity(5);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
const trigger = screen.getByRole("button", { name: "evicted.example" });
act(() => trigger.focus());
fireEvent.click(trigger);
evictWithFiller();
expect(trigger.isConnected).toBe(false);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
// Never the body, never whichever row happens to sit where the old one did,
// and never the Freeze button: a stable anchor in the region the reader was
// reading. React Aria's own deferred restore runs after this and, finding
// focus already placed, leaves it alone.
const region = screen.getByRole("region", { name: "Live queries" });
expect(document.activeElement).toBe(region);
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
expect(document.activeElement).toBe(region);
});
test("the route renders the live ring at its production capacity", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "pinned.example")));
expect(screen.getByText(/last 500 kept/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
expect(screen.getByText(/newest 500 kept/)).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" }));
const snapshot = detailDialog().textContent;
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();
// Freeze is behind the dialog, so it is reached the way the code reaches it
// rather than by role, which the modal deliberately hides.
const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
act(() => freeze().click());
expect(detailDialog().textContent).toBe(snapshot);
act(() => (screen.getByText("Resume") as HTMLButtonElement).click());
expect(detailDialog().textContent).toBe(snapshot);
});
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
test("live renders no filter toolbar, not even a disabled one", 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);
}
// The stream is unfiltered — the server sends every query — so a row of
// controls here would promise filtering that is not happening. The filters
// are not lost: they are in the URL, and History applies them on the way back.
expect(screen.queryByLabelText("Filter domains")).toBeNull();
expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
expect(screen.queryByText("Clear")).toBeNull();
expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
});
test("live mode asks for no query pages, whatever filters the url retained", async () => {
@@ -383,17 +545,43 @@ test("live mode asks for no query pages, whatever filters the url retained", asy
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" });
fireEvent.click(screen.getByRole("tab", { name: "History" }));
await screen.findByLabelText("Filter domains");
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);
expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
fireEvent.click(screen.getByRole("button", { name: "Live" }));
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
await screen.findByRole("button", { name: "Freeze" });
expect(sources).toHaveLength(2);
expect(sources[1]!.closed).toBe(false);
});
/** The related-actions region of a query detail. */
function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" });
}
/**
* The streamed detail carries the same Related as the persisted one: four links
* and no control. Pause is resolver-wide and lives in the sidebar alone.
*/
test("a streamed blocked row's Related carries links only", async () => {
await openLive();
act(() =>
sources[0]!.emit(
"query",
frame(1000, "streamed.example", {
policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" },
route: { kind: "blocked", upstream: "" },
}),
),
);
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).getAllByRole("link")).toHaveLength(4);
expect(within(related()).queryByRole("button")).toBeNull();
});
+123 -138
View File
@@ -1,6 +1,6 @@
/**
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
* in-place detail a streamed row opens.
* Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
* 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
@@ -16,7 +16,8 @@ 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 { summarizeEvent } from "@/features/provenance/querySummary";
import Dialog from "@/ui/Dialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
@@ -88,6 +89,7 @@ const styles = stylex.create({
lineHeight: "1.25rem",
},
dismiss: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
@@ -161,29 +163,6 @@ const styles = stylex.create({
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",
@@ -192,10 +171,6 @@ const styles = stylex.create({
},
});
/** 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",
@@ -225,40 +200,15 @@ function StatusPill({ status }: { status: StreamStatus }) {
*
* 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.
* dialog would blank out or swap under their eyes 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. The stream behind it never stops.
*/
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>
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
<ProvenanceDetail
provenance={row.event}
persistedId={null}
@@ -271,30 +221,56 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
/>
}
/>
</div>
</Dialog>
);
}
export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
const live = useLiveQueries();
/**
* `capacity` is the ring size, a parameter only so a test can provoke an
* eviction with a handful of rows rather than 500 frames through React state.
* The route renders this without it, so the app is always the 500-row ring.
*/
export default function LiveActivity({
origin,
capacity = RING_CAPACITY,
}: {
origin: ActivitySearch;
capacity?: number;
}) {
const live = useLiveQueries({ capacity });
const clientNames = useClientNames();
const [selected, setSelected] = useState<StreamedRow | null>(null);
const trigger = useRef<HTMLButtonElement | null>(null);
const results = useRef<HTMLDivElement>(null);
const restoring = useRef(false);
/**
* Where focus lands when the dialog closes.
*
* React Aria restores focus itself, but in a `requestAnimationFrame` and
* only while focus is still on the body and its target is the row button,
* which the ring may have evicted while the reader was reading. So this runs
* in the effect that follows the focus scope's teardown and puts focus on a
* connected element first: the row if it is still there, the results region
* if it is not. React Aria's deferred pass then finds focus already placed
* and does nothing, so the two never fight over it. If a navigation unmounts
* this component the effect never runs, which is the right answer there is
* no longer a table to return to.
*/
useEffect(() => {
if (selected !== null || !restoring.current) return;
restoring.current = false;
const from = trigger.current;
trigger.current = null;
(from?.isConnected === true ? from : results.current)?.focus();
}, [selected]);
function open(row: StreamedRow, from: HTMLButtonElement) {
trigger.current = from;
restoring.current = true;
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)}>
@@ -311,8 +287,7 @@ export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
{live.frozen && (
<p {...stylex.props(styles.note)} role="status">
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
kept).
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept).
</p>
)}
@@ -351,71 +326,81 @@ export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
</div>
)}
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
{/*
* The region is the anchor focus falls back to when the row that
* opened the dialog is gone, so it is rendered unconditionally: an
* anchor that disappears with the last row is no anchor at all.
*/}
<div
ref={results}
tabIndex={-1}
role="region"
aria-label="Live queries"
{...stylex.props(shared.focusRing)}
>
{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-haspopup="dialog"
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 {capacity} kept).
</p>
</>
)}
</div>
{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>
</>
)}
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
</>
);
}
@@ -19,8 +19,8 @@ import {
qclassName,
rcodeName,
routeKindLabel,
} from "@/features/queries/provenanceCopy";
import { qtypeName } from "@/features/queries/qtype";
} from "@/features/provenance/provenanceCopy";
import { qtypeName } from "@/features/provenance/qtype";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -274,8 +274,13 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
</Section>
</div>
<div {...stylex.props(styles.related)}>
<h2 {...stylex.props(styles.relatedHeading)}>Related</h2>
{/* A named region, because the Pause it may offer is not the only Pause
on screen: the sidebar carries one too, and the two answer different
questions. */}
<section aria-labelledby="related-actions" {...stylex.props(styles.related)}>
<h2 id="related-actions" {...stylex.props(styles.relatedHeading)}>
Related
</h2>
<p {...stylex.props(styles.relatedNote)}>
These read the current configuration, which may no longer be the one that decided this query.
</p>
@@ -294,7 +299,7 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
</p>
)}
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
</div>
</section>
</>
);
}
+2 -2
View File
@@ -1,8 +1,8 @@
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 { queryRow } from "@/features/provenance/provenanceFixture";
import { summarizeRow, type QuerySummary } from "@/features/provenance/querySummary";
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
const noNames: ClientNames = new Map();
+3 -3
View File
@@ -17,9 +17,9 @@ 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 { rcodeShortName } from "@/features/provenance/provenanceCopy";
import { qtypeName } from "@/features/provenance/qtype";
import type { QuerySummary } from "@/features/provenance/querySummary";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -0,0 +1,58 @@
import { diagnosticsBounds, relatedBounds, RELATED_WINDOW_SECONDS } from "./relatedLinks";
import { queriesFilterOf, validateActivitySearch } from "./search";
const TS = 1_700_000_000;
test("an unbounded origin falls back to the window either side of the query", () => {
expect(relatedBounds(TS, { since: undefined, until: undefined })).toEqual({
since: TS - RELATED_WINDOW_SECONDS,
until: TS + RELATED_WINDOW_SECONDS,
});
});
test("a bounded origin carries both of its bounds through unchanged", () => {
expect(relatedBounds(TS, { since: 1, until: 2 })).toEqual({ since: 1, until: 2 });
});
test("a half-bounded origin keeps its half and falls back on the other", () => {
expect(relatedBounds(TS, { since: 1, until: undefined })).toEqual({
since: 1,
until: TS + RELATED_WINDOW_SECONDS,
});
expect(relatedBounds(TS, { since: undefined, until: 2 })).toEqual({
since: TS - RELATED_WINDOW_SECONDS,
until: 2,
});
});
test("an origin bound of zero is a bound, not a missing one", () => {
expect(relatedBounds(TS, { since: 0, until: 0 })).toEqual({ since: 0, until: 0 });
});
test("the diagnostics window is the fixed window either side of the query, never inherited", () => {
expect(diagnosticsBounds(TS)).toEqual({ since: TS - RELATED_WINDOW_SECONDS, until: TS + RELATED_WINDOW_SECONDS });
expect(diagnosticsBounds(0)).toEqual({ since: -RELATED_WINDOW_SECONDS, until: RELATED_WINDOW_SECONDS });
});
/**
* The admin half of the server's `since <= ts < until` window contract
* (queries_repo.zig): what a related link emits has to survive the validation
* the Activity route puts every search through, or the link would silently open
* a wider window than it named. Inclusion at the edges is the server's property
* and is tested there; this pins that the bounds arrive intact.
*/
test("bounds emitted by a related link round-trip through the Activity search to the same filter", () => {
const origin = { since: undefined, until: TS + 3_600 };
const emitted = { mode: "history", domain: "ads.example.com", ...relatedBounds(TS, origin) };
const applied = validateActivitySearch(emitted);
expect(applied.mode).toBe("history");
expect(applied.since).toBe(emitted.since);
expect(applied.until).toBe(emitted.until);
expect(queriesFilterOf(applied)).toEqual({
domain: "ads.example.com",
since: TS - RELATED_WINDOW_SECONDS,
until: TS + 3_600,
});
});
@@ -1,5 +1,5 @@
import type { Provenance, QueryRow } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { provenance, queryRow } from "@/features/provenance/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 {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
import { summarizeEvent, summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
import { summarizeEvent, summarizeRow, type QuerySummary } from "@/features/provenance/querySummary";
/**
* A row in the live buffer. `key` is a client-side monotonic counter, because
@@ -1,7 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { ApiError } from "@/lib/api";
import type { QueriesPage, QueryRow } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
import { summaryOf, type LiveRow } from "./ringBuffer";
import { FakeEventSource } from "./fakeEventSource";
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
+18 -8
View File
@@ -24,6 +24,12 @@ export interface LiveQueriesOptions {
fetchSince?: (since: number) => Promise<QueriesPage>;
/** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */
probeSession?: () => Promise<unknown>;
/**
* Ring size. Injectable so a test can provoke an eviction with a handful of
* rows instead of pushing 500 frames through React state; the app never
* passes it, and the operator never sees it.
*/
capacity?: number;
}
// A transient drop is invisible to EventSource beyond a bare `error` event;
@@ -34,7 +40,10 @@ export interface LiveQueriesOptions {
export const CAP_ERROR_THRESHOLD = 3;
const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
const defaultFetchSince = (since: number): Promise<QueriesPage> => api.getQueries({ since, limit: RING_CAPACITY });
const defaultFetchSince =
(capacity: number) =>
(since: number): Promise<QueriesPage> =>
api.getQueries({ since, limit: capacity });
const defaultProbeSession = (): Promise<unknown> => api.getPause();
function isUnauthorized(error: unknown): boolean {
@@ -79,7 +88,8 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
errorsRef.current = 0;
setStatus("connecting");
const opts = optionsRef.current;
const fetchSince = opts?.fetchSince ?? defaultFetchSince;
const capacity = opts?.capacity ?? RING_CAPACITY;
const fetchSince = opts?.fetchSince ?? defaultFetchSince(capacity);
const probeSession = opts?.probeSession ?? defaultProbeSession;
const es = (opts?.createEventSource ?? defaultEventSource)(opts?.url ?? api.liveQueriesUrl);
esRef.current = es;
@@ -94,7 +104,7 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
fetchSince(since).then(
(page) => {
if (esRef.current !== es) return;
const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current);
const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current, capacity);
bufferRef.current = merged.rows;
setRows(merged.rows);
setMissed(merged.missed);
@@ -122,11 +132,11 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
return;
}
lastSeenTsRef.current = payload.request.time;
bufferRef.current = pushRow(bufferRef.current, {
kind: "streamed",
event: payload,
key: ++keyRef.current,
});
bufferRef.current = pushRow(
bufferRef.current,
{ kind: "streamed", event: payload, key: ++keyRef.current },
capacity,
);
setRows(bufferRef.current);
});
@@ -1,279 +0,0 @@
import { act, 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 { clearRefreshStatus } from "@/features/blocklists/refreshStore";
const BLOCKLISTS = {
blocklists: [
{
id: 1,
url: "https://example.com/hosts.txt",
name: "StevenBlack",
enabled: true,
is_suggested: true,
last_updated: 1700000000,
domain_count: 1000,
wildcard_count: 10,
exception_count: 7,
skipped_regex_count: 3,
skipped_unsupported_count: 21,
checksum: "abc",
},
{
id: 2,
url: "https://example.org/list.txt",
name: "Custom",
enabled: false,
is_suggested: false,
last_updated: null,
domain_count: 0,
wildcard_count: 0,
exception_count: 0,
skipped_regex_count: 0,
skipped_unsupported_count: 0,
checksum: null,
},
],
};
const RESPONSES: Record<string, unknown> = {
"/api/blocklists": BLOCKLISTS,
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
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;
});
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderBlocklistsRoute(queryClient = createQueryClient()) {
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient);
const view = render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { queryClient, unmount: view.unmount };
}
const SNAPSHOT = {
sources: [
{
id: 1,
state: "loaded",
loaded: true,
last_attempt: 1700000100,
last_success: 1700000100,
url: "https://example.com/hosts.txt",
last_error: "",
domains: 1200,
wildcards: 12,
exceptions: 9,
skipped_regex: 4,
skipped_unsupported: 17,
},
],
};
test("renders the source table and the status empty state", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
expect(screen.getByText("StevenBlack")).toBeTruthy();
expect(screen.getByText("https://example.com/hosts.txt")).toBeTruthy();
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);
const disabledToggle = screen.getByLabelText("Custom enabled") as HTMLInputElement;
expect(disabledToggle.checked).toBe(false);
expect(screen.getByText(/run .Update now. to fetch status/)).toBeTruthy();
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
});
test("update now disables the button, then replaces the status section from the 202 snapshot", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
const button = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
fireEvent.click(button);
const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement;
expect(pending.disabled).toBe(true);
expect(resolveUpdate).not.toBeNull();
const snapshot = {
sources: [
{
id: 1,
state: "loaded",
loaded: true,
last_attempt: 1700000100,
last_success: 1700000100,
url: "https://example.com/hosts.txt",
last_error: "",
domains: 1200,
wildcards: 12,
exceptions: 9,
skipped_regex: 4,
skipped_unsupported: 17,
},
{
id: 2,
state: "fetch_failed",
loaded: false,
last_attempt: 1700000100,
last_success: 0,
url: "https://example.org/list.txt",
last_error: "connect timed out",
domains: 0,
wildcards: 0,
exceptions: 0,
skipped_regex: 0,
skipped_unsupported: 0,
},
],
};
resolveUpdate!(
new Response(JSON.stringify(snapshot), { status: 202, headers: { "content-type": "application/json" } }),
);
await screen.findByText("loaded");
expect(screen.getByText("fetch_failed")).toBeTruthy();
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/);
await waitFor(() => {
const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
expect(idle.disabled).toBe(false);
});
});
test("update now shows a countdown when rate limited with Retry-After", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await screen.findByRole("button", { name: "Updating…" });
expect(resolveUpdate).not.toBeNull();
resolveUpdate!(
new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "7" },
}),
);
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
});
test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { queryClient, unmount } = renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await screen.findByRole("button", { name: "Updating…" });
resolveUpdate!(
new Response(JSON.stringify(SNAPSHOT), {
status: 202,
headers: { "content-type": "application/json" },
}),
);
await screen.findByText("loaded");
unmount();
// Well past the default 5-minute gcTime: an unsubscribed cache entry is
// collected by now, which is what used to erase the snapshot.
await act(async () => {
await vi.advanceTimersByTimeAsync(6 * 60_000);
});
renderBlocklistsRoute(queryClient);
await screen.findByRole("heading", { name: "Blocklists" });
expect(await screen.findByText("loaded")).toBeTruthy();
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
} finally {
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"]));
});
@@ -1,134 +0,0 @@
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>
);
}
@@ -1,31 +0,0 @@
import { useSyncExternalStore } from "react";
import type { SourceStatus } from "@/lib/types";
// Client UI state, not server state: the snapshot exists only as the 202 body of
// POST /api/blocklists/update and no GET can refetch it. Held here so it outlives
// the query cache's gcTime instead of vanishing from an unsubscribed cache entry.
let snapshot: SourceStatus[] | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): SourceStatus[] | null {
return snapshot;
}
export function setRefreshStatus(sources: SourceStatus[]): void {
snapshot = sources;
for (const listener of listeners) listener();
}
export function clearRefreshStatus(): void {
snapshot = null;
for (const listener of listeners) listener();
}
export function useRefreshStatus(): SourceStatus[] | null {
return useSyncExternalStore(subscribe, getSnapshot);
}
@@ -0,0 +1,107 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { BASE, MANAGED_FILE, NEVER, renderAt } from "./testFixtures";
afterEach(() => {
vi.unstubAllGlobals();
});
test("a cold deep link fetches the list once and renders the client (D9)", async () => {
const { fetchMock } = renderAt("/clients/1", BASE);
await screen.findByRole("heading", { name: "laptop" });
expect(screen.getByText("192.168.1.10")).toBeTruthy();
const listCalls = fetchMock.mock.calls.filter(([input]) => String(input) === "/api/clients");
expect(listCalls).toHaveLength(1);
});
test("identity carries the name, its provenance and both timestamps", async () => {
renderAt("/clients/2", BASE);
// The unnamed row is the learned one, and Clients is where the marker shows.
const heading = await screen.findByRole("heading", { name: /kids-tablet\.lan/ });
expect(within(heading).getByText("learned")).toBeTruthy();
expect(screen.getByText(/This client appeared from DNS traffic/)).toBeTruthy();
expect(screen.getByText("First seen")).toBeTruthy();
expect(screen.getByText("Last seen")).toBeTruthy();
});
test("a hand-set row reads as edited under database authority", async () => {
renderAt("/clients/1", BASE);
await waitFor(() => expect(screen.getByText(/The name and group were set here/)).toBeTruthy());
});
test("the same row reads as declared under file authority", async () => {
renderAt("/clients/1", { ...BASE, "GET /api/config/status": MANAGED_FILE });
await screen.findByText(/Declared in \/etc\/nxdns\/config\.zon/);
});
test("an unresolved authority names the doubt instead of picking a provenance", async () => {
renderAt("/clients/1", { ...BASE, "GET /api/config/status": NEVER });
await screen.findByText(/Either declared in the configuration file or edited here/);
});
test("an id the list does not contain renders the missing-client state (D9)", async () => {
renderAt("/clients/99", BASE);
await screen.findByRole("heading", { name: "No such client" });
expect(screen.getByText(/no client with id 99/i)).toBeTruthy();
expect(screen.getByRole("link", { name: "← All clients" })).toBeTruthy();
});
test("policy links to the group that filters this client", async () => {
renderAt("/clients/2", BASE);
const link = await screen.findByRole("link", { name: "Group settings" });
expect(link.getAttribute("href")).toBe("/configuration/protection?tab=groups&group=2");
});
test("the Activity affordance is a real link, with bounds minted at interaction time", async () => {
const { router } = renderAt("/clients/1", BASE);
const link = await screen.findByRole("link", { name: /last 24 hours/i });
// A real <a href> is what makes middle-click, copy-link and open-in-new-tab
// work; the bounds must be in that href before the click, not minted by a
// handler the browser never runs on those gestures.
expect(link.tagName).toBe("A");
expect(link.getAttribute("href")).toMatch(/^\/activity\?/);
const before = Math.floor(Date.now() / 1000);
fireEvent.pointerDown(link);
fireEvent.click(link);
const after = Math.floor(Date.now() / 1000);
await waitFor(() => expect(router.state.location.pathname).toBe("/activity"));
const search = router.state.location.search as { mode: string; client: string; since: number; until: number };
expect(search.mode).toBe("history");
expect(search.client).toBe("192.168.1.10");
expect(search.until).toBeGreaterThanOrEqual(before - 1);
expect(search.until).toBeLessThanOrEqual(after + 1);
expect(search.until - search.since).toBe(24 * 60 * 60);
});
test("keyboard activation re-mints the bounds, not the focus that preceded it by hours", async () => {
const { router } = renderAt("/clients/1", BASE);
const link = await screen.findByRole("link", { name: /last 24 hours/i });
// Focus can be hours old by the time Enter lands, so the window it minted is
// stale; the keypress itself is what must be bracketed.
fireEvent.focus(link);
const pressedAt = Date.now() + 3 * 60 * 60 * 1000;
const clock = vi.spyOn(Date, "now").mockReturnValue(pressedAt);
try {
fireEvent.keyDown(link, { key: "Enter" });
// jsdom does not synthesize the click Enter produces on a real anchor.
fireEvent.click(link);
} finally {
clock.mockRestore();
}
await waitFor(() => expect(router.state.location.pathname).toBe("/activity"));
const search = router.state.location.search as { client: string; since: number; until: number };
expect(search.client).toBe("192.168.1.10");
expect(search.until).toBe(Math.floor(pressedAt / 1000));
expect(search.until - search.since).toBe(24 * 60 * 60);
});
@@ -0,0 +1,225 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { formatTime } from "@/lib/format";
import { clientsQuery } from "@/lib/queries";
import { useAuthority } from "@/features/configuration/authority";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ClientDisplayName, provenanceOf } from "./clientIdentity";
/** The Activity window this page offers: the last day, in seconds. */
const ACTIVITY_WINDOW_SECONDS = 24 * 60 * 60;
const nowInSeconds = () => Math.floor(Date.now() / 1000);
const styles = stylex.create({
back: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
heading: {
marginTop: "0.5rem",
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
address: {
marginTop: "0.25rem",
color: colors.textSecondary,
},
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,
},
provenanceDetail: {
marginTop: "0.125rem",
color: colors.textMuted,
},
sectionHeading: {
marginTop: "1.5rem",
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
prose: {
marginTop: "0.5rem",
maxWidth: "48rem",
fontSize: "0.875rem",
lineHeight: "1.5rem",
},
links: {
marginTop: "0.75rem",
display: "flex",
flexWrap: "wrap",
gap: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
link: {
color: colors.primaryOnSurface,
},
loading: {
marginTop: "1rem",
color: colors.textMuted,
},
});
function BackLink() {
return (
<Link to="/clients" search={{}} {...stylex.props(styles.back, shared.focusRing)}>
All clients
</Link>
);
}
/**
* One client, addressed by the row id `PUT /api/clients/{id}` already uses.
*
* No endpoint answers for a single client, so the list is the source: the route
* loader ensures it, which makes a cold deep link one fetch rather than a
* miss. An id the loaded list does not contain is a row that was deleted or
* never existed a state this page explains once, with no refetch behind it.
*/
export default function ClientDetailPage() {
const { id } = useParams({ from: "/shell/clients/$id" });
const clientId = Number(id);
const { data, error, isPending, refetch } = useQuery(clientsQuery());
const authority = useAuthority();
// The Activity window is relative to now, and a real link must carry its
// bounds in the href before the click — that is what makes middle-click,
// copy-link and open-in-new-tab work. A page left open would otherwise link
// to yesterday's day, so the window is re-minted when the link is about to be
// used: pointerdown precedes the click, and keydown precedes the click Enter
// synthesizes. Focus re-mints too, but it cannot be the last word — a link can
// hold focus for hours before the keypress. All three are discrete events, so
// the href is fresh by the time navigation reads it.
const [activityUntil, setActivityUntil] = useState(nowInSeconds);
const freshenActivityWindow = () => setActivityUntil(nowInSeconds());
if (isPending) {
return (
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
Loading client
</p>
);
}
if (data === undefined) {
return (
<section>
<BackLink />
<InlineError error={error} onRetry={() => void refetch()} />
</section>
);
}
const client = data.find((row) => row.id === clientId);
if (client === undefined) {
return (
<section>
<BackLink />
<h1 {...stylex.props(styles.heading)}>No such client</h1>
<p {...stylex.props(styles.prose)}>
nxdns has no client with id {id}. It was deleted, or the link was to a row that never existed. A
device that is still on the network reappears in the list on its next DNS query.
</p>
</section>
);
}
const provenance = provenanceOf(client, authority);
return (
<section>
<BackLink />
<h1 {...stylex.props(styles.heading)}>
<ClientDisplayName client={client} />
</h1>
<p {...stylex.props(styles.address, shared.mono)}>{client.ip}</p>
<div {...stylex.props(styles.panel)}>
<dl {...stylex.props(styles.facts)}>
<dt {...stylex.props(styles.term)}>Name</dt>
<dd {...stylex.props(styles.value)}>
<ClientDisplayName client={client} />
</dd>
<dt {...stylex.props(styles.term)}>Provenance</dt>
<dd {...stylex.props(styles.value)}>
{provenance.label}
<span {...stylex.props(styles.provenanceDetail)}> {provenance.detail}</span>
</dd>
<dt {...stylex.props(styles.term)}>First seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(client.first_seen)}</dd>
<dt {...stylex.props(styles.term)}>Last seen</dt>
<dd {...stylex.props(styles.value)}>{formatTime(client.last_seen)}</dd>
</dl>
</div>
<h2 {...stylex.props(styles.sectionHeading)}>Policy</h2>
<p {...stylex.props(styles.prose)}>
Filtering for this client follows the <strong>{client.group}</strong> group: its safe search setting,
its blocklist sources and its rules.
</p>
<p {...stylex.props(styles.links)}>
<Link
to="/configuration/protection"
search={{ tab: "groups", group: client.group_id }}
{...stylex.props(styles.link, shared.focusRing)}
>
Group settings
</Link>
</p>
<h2 {...stylex.props(styles.sectionHeading)}>Activity</h2>
<p {...stylex.props(styles.links)}>
<Link
to="/activity"
search={{
mode: "history",
client: client.ip,
since: activityUntil - ACTIVITY_WINDOW_SECONDS,
until: activityUntil,
domain: undefined,
blocked: undefined,
}}
onPointerDown={freshenActivityWindow}
onFocus={freshenActivityWindow}
onKeyDown={(event) => {
if (event.key === "Enter") freshenActivityWindow();
}}
{...stylex.props(styles.link, shared.focusRing)}
>
Queries from this client, last 24 hours
</Link>
</p>
</section>
);
}
+37 -22
View File
@@ -4,10 +4,12 @@ import * as stylex from "@stylexjs/stylex";
import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
import { useReadOnlyConfig } from "@/features/configuration/authority";
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";
import { colors } from "@/ui/tokens.stylex";
interface Props {
client: Client;
@@ -16,16 +18,10 @@ interface Props {
}
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",
@@ -39,28 +35,40 @@ const styles = stylex.create({
},
actions: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
lockNote: {
marginRight: "auto",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
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);
// Adopting the learned name as a typed one is the natural gesture.
const [name, setName] = useState(client.name === "" ? client.learned_name : client.name);
const [groupId, setGroupId] = useState(client.group_id);
// The dialog opens only where the save can land, but authority is polled and
// can turn under an open dialog. So the save path consults it on every render
// rather than trusting the state that was true when the dialog opened; the
// draft stays on screen, locked, instead of vanishing mid-edit.
const readOnly = useReadOnlyConfig();
return (
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
<Dialog title={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<form
{...stylex.props(styles.form)}
onSubmit={(event) => {
event.preventDefault();
// Enter in the name field submits without the Save button, so
// the lock lives here too, not only in what is rendered.
if (readOnly) return;
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
@@ -86,17 +94,24 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
/>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.actions)}>
{readOnly && (
<span {...stylex.props(styles.lockNote)}>
This edit can no longer be saved.
<ConfigLockIndicator />
</span>
)}
<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>
{!readOnly && (
<button
type="submit"
disabled={mutation.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save
</button>
)}
</div>
</form>
</Dialog>
+404 -97
View File
@@ -1,121 +1,125 @@
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";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { ClientName, type ClientNames } from "./clientNames";
import { BASE, CLIENTS, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
const CLIENTS = {
clients: [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "laptop-1.lan",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "kids-tablet.lan",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
],
};
const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
function stubFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
async function renderClientsPage(map: Record<string, unknown>) {
stubFetch(map);
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: "Clients" });
function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr");
if (row === null) throw new Error(`no client row for ${ip}`);
return row;
}
const BASE = {
"GET /api/clients": CLIENTS,
"GET /api/client-prefixes": PREFIXES,
"GET /api/groups": GROUPS,
"GET /api/version": VERSION,
};
function assignmentsSection(): HTMLElement {
const heading = screen.getByRole("heading", { name: /Network assignments/ });
const section = heading.closest("section");
if (section === null) throw new Error("no network assignments section");
return section;
}
/**
* Editing a client writes configuration, so the affordance is absent until the
* status query says the database owns it never disabled, never assumed.
*/
async function unlockedEdit(index: number): Promise<HTMLButtonElement> {
const buttons = await screen.findAllByRole("button", { name: "Edit" });
return buttons[index] as HTMLButtonElement;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("renders the client table with group names and one hand-edited badge", async () => {
await renderClientsPage(BASE);
test("renders the identity-first table: address, name, group, first and last seen", async () => {
await renderClientsPage();
const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent);
expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]);
expect(screen.getByText("192.168.1.10")).toBeTruthy();
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(screen.getByText("laptop")).toBeTruthy();
expect(screen.getAllByText("edited")).toHaveLength(1);
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);
await renderClientsPage();
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);
test("an unnamed row shows the learned name with the learned marker", async () => {
await renderClientsPage();
// The cell holds the learned name followed by the tag, so the match is on
// The cell holds the learned name followed by the marker, 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.
// The marker is text, not colour, so a screen reader announces it too.
expect(within(learned).getByText("learned")).toBeTruthy();
});
test("the learned marker is a Clients-page affordance: query tables render the same name without it", () => {
const names: ClientNames = new Map([["192.168.1.11", { name: "", learned_name: "kids-tablet.lan" }]]);
render(<ClientName ip="192.168.1.11" names={names} />);
expect(screen.getByText("kids-tablet.lan")).toBeTruthy();
expect(screen.queryByText("learned")).toBeNull();
});
test("the address links to the client's detail page", async () => {
await renderClientsPage();
const link = within(clientRow("192.168.1.10")).getByRole("link", { name: "192.168.1.10" });
expect(link.getAttribute("href")).toBe("/clients/1");
});
test("delete asks first, naming the row, and the confirmation carries out the delete", async () => {
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
// The operator has to be able to tell from the dialog alone which row this is.
expect(within(dialog).getByText(/kids-tablet\.lan \(192\.168\.1\.11\)/)).toBeTruthy();
expect(within(dialog).getByText(/re-materialize on their next DNS query/)).toBeTruthy();
// Asking is not deleting.
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2")),
).toHaveLength(1),
);
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
});
test("cancelling the confirmation keeps the client", async () => {
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
});
test("shows the DNS-activity empty state when there are no clients", async () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
@@ -124,9 +128,9 @@ test("shows the DNS-activity empty state when there are no clients", async () =>
});
test("edit opens a dialog seeded with the client's name and group", async () => {
await renderClientsPage(BASE);
await renderClientsPage();
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
fireEvent.click(await unlockedEdit(0));
// 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");
@@ -135,9 +139,9 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
});
test("the group picker offers every group and reports the choice", async () => {
await renderClientsPage(BASE);
await renderClientsPage();
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
fireEvent.click(await unlockedEdit(0));
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
@@ -148,14 +152,317 @@ test("the group picker offers every group and reports the choice", async () => {
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
});
test("prefix editor starts clean and dirties on add", async () => {
await renderClientsPage(BASE);
test("network assignments start clean and dirty on add", async () => {
await renderClientsPage();
expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24");
const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement;
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
expect(range.value).toBe("192.168.1.0/24");
const save = screen.getByRole("button", { name: "Save assignments" }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Add prefix" }));
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
expect(save.disabled).toBe(false);
expect((screen.getByLabelText("Prefix 2") as HTMLInputElement).value).toBe("");
expect((screen.getByLabelText("Range 2") as HTMLInputElement).value).toBe("");
});
test("?group= filters the list to that group", async () => {
const { router } = await renderClientsPage();
await router.navigate({ to: "/clients", search: { group: 2 } });
await waitFor(() => expect(screen.queryByText("192.168.1.10")).toBeNull());
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(screen.getByText("Showing clients in kids.")).toBeTruthy();
});
test("an unknown group id filters to nothing and offers a way out", async () => {
const { router } = await renderClientsPage();
await router.navigate({ to: "/clients", search: { group: 999 } });
await screen.findByText("No group with id 999 exists.");
expect(screen.getByText("No clients match this filter.")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "Clear filter" }));
await waitFor(() => expect(screen.getByText("192.168.1.10")).toBeTruthy());
expect(router.state.location.search).toEqual({});
});
// Both statuses lock the declared delete, but only file authority proves the
// file declares the row; the anchors keep the two sentences apart.
const DECLARED_NOTE = /^This client is declared in the configuration file/;
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
// The settled sentence, not the tag: "Locked" is already on screen while
// authority is pending, so waiting on it would not wait for this status.
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
const declared = clientRow("192.168.1.10");
const observed = clientRow("192.168.1.11");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
// Why the locked Delete will not answer, in visible text and exactly once:
// per row it would repeat down the whole page, and on the button it was a
// title that a keyboard and a touch screen never reached.
expect(screen.getAllByText(DECLARED_NOTE, { selector: "p" })).toHaveLength(1);
expect(within(declared).queryByText(DECLARED_NOTE, { selector: "p" })).toBeNull();
// The description stays on the button itself too, for a reader on that control.
const locked = within(declared).getByRole("button", { name: "Delete" });
expect(document.getElementById(locked.getAttribute("aria-describedby") ?? "")?.textContent).toMatch(DECLARED_NOTE);
});
test("an all-observed page still says why Edit is gone, with no delete note to carry it", async () => {
// Every row observed, so no Delete is locked. The edit lock is still real, and
// "Locked" appearing with nothing to explain it is the failure this guards.
const observedOnly = { clients: [CLIENTS.clients[1]] };
await renderClientsPage({
...BASE,
"GET /api/clients": observedOnly,
"GET /api/config/status": MANAGED_FILE,
});
// The settled sentence is both the anchor and the assertion: it is the whole
// explanation for the missing Edit action.
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
expect(await screen.findAllByText("Locked")).not.toHaveLength(0);
// The delete sentence belongs only to a row that has one.
expect(screen.queryByText(DECLARED_NOTE)).toBeNull();
expect((screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
const section = await waitFor(() => {
const found = assignmentsSection();
if (found.querySelector("table") === null) throw new Error("still editing");
return found;
});
expect(within(section).getByText("192.168.1.0/24")).toBeTruthy();
expect(
section.querySelectorAll(
"input, textarea, select, [role='combobox'], [role='checkbox'], [contenteditable], button",
),
).toHaveLength(0);
});
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
await screen.findAllByText(/^Configuration status unavailable/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
expect(within(assignmentsSection()).getByText(/Configuration status unavailable/)).toBeTruthy();
const observed = clientRow("192.168.1.11");
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
const declared = clientRow("192.168.1.10");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
});
// The status query polls, so authority can turn while a dialog or a delete
// confirmation is already open. `undefined` is the failed status: the fetch stub
// answers 404 for a key it does not hold.
//
describe.each([
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
])("authority turning to %s under an open affordance", (_label, status, lockNote, otherNote) => {
test("locks the open edit dialog's save path and leaves cancel working", async () => {
const map = { ...BASE };
const { queryClient } = await renderClientsPage(map);
fireEvent.click(await unlockedEdit(0));
expect(
within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })).getByRole("button", {
name: "Save",
}),
).toBeTruthy();
await setConfigStatus(map, queryClient, status);
// The draft survives; only the save path goes, and it says why.
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
expect(within(dialog).getByText("Locked")).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => {
const map = { ...BASE };
const { queryClient, fetchMock } = await renderClientsPage(map);
// The Edit affordance appearing is the proof authority resolved to
// database; the transition under test starts from there.
await unlockedEdit(0);
const declared = clientRow("192.168.1.10");
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status);
// The dialog stays put. Closing it would throw focus at the Delete button
// the same turn disabled, and the reason would survive only as a title
// attribute; here the reason is the dialog's own message.
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
expect(within(dialog).getByText(lockNote)).toBeTruthy();
expect(within(dialog).queryByText(otherNote)).toBeNull();
expect(within(dialog).getByText("Locked")).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
});
test("a cancelled confirmation stays closed when authority comes back", async () => {
const map = { ...BASE };
const { queryClient } = await renderClientsPage(map);
await unlockedEdit(0);
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
// A poll that fails and then recovers must not raise a destructive
// question the operator already answered.
await setConfigStatus(map, queryClient, status);
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
await waitFor(() => expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0));
expect(screen.queryByRole("alertdialog")).toBeNull();
});
test("the confirm action comes back when authority does, without a second prompt", async () => {
const map = { ...BASE, "DELETE /api/clients/1": {} };
const { queryClient, fetchMock } = await renderClientsPage(map);
await unlockedEdit(0);
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status);
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
// The question was never withdrawn, so the answer returns to the same
// dialog rather than asking the operator to start again.
const confirm = await within(dialog).findByRole("button", { name: "Delete" });
fireEvent.click(confirm);
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
),
).toHaveLength(1),
);
});
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
const map = { ...BASE, "DELETE /api/clients/2": {} };
const { queryClient, fetchMock } = await renderClientsPage(map);
// The Edit affordance appearing is the proof authority resolved to
// database; the transition under test starts from there.
await unlockedEdit(0);
const observed = clientRow("192.168.1.11");
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status);
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2"),
),
).toHaveLength(1),
);
});
});
test("a pending config status holds the same line as a failed one (R3-4)", async () => {
// The status request never settles, so authority stays pending for the whole
// test: nothing configuration owns may be offered on that guess.
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
await screen.findAllByText(/^Checking which configuration source/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
expect(within(assignmentsSection()).getByText(/Checking which configuration source/)).toBeTruthy();
const observed = clientRow("192.168.1.11");
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});
test("a save-time problem marks the input it is about and describes it", async () => {
await renderClientsPage();
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
fireEvent.change(range, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBe("true");
expect(describedText(range)).toBe("Row 1: prefix is required.");
// Only the offending input is marked; the priority beside it is untouched.
const priority = screen.getByLabelText("Priority for range 1");
expect(priority.getAttribute("aria-invalid")).toBeNull();
expect(priority.getAttribute("aria-describedby")).toBeNull();
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBeNull();
expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe(
"Row 1: priority must be a whole number.",
);
});
test("removing a row drops the message rather than moving it to another input", async () => {
await renderClientsPage();
// Row 1 is the offending one, so removing it is what would slide the stale
// index onto row 2 — an input that validated cleanly.
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
fireEvent.change(range, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBe("true");
expect(describedText(range)).toBe("Row 1: prefix is required.");
const section = assignmentsSection();
fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement);
const survivor = screen.getByLabelText("Range 1") as HTMLInputElement;
expect(survivor.value).toBe("10.0.0.0/8");
expect(survivor.getAttribute("aria-invalid")).toBeNull();
expect(survivor.getAttribute("aria-describedby")).toBeNull();
expect(within(section).queryByRole("alert")).toBeNull();
expect(screen.queryByLabelText("Range 2")).toBeNull();
});
test("editing a row clears the message it was about", async () => {
await renderClientsPage();
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
fireEvent.change(range, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBe("true");
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
expect(range.getAttribute("aria-invalid")).toBeNull();
expect(within(assignmentsSection()).queryByRole("alert")).toBeNull();
});
+154 -107
View File
@@ -1,22 +1,49 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { Link, useSearch } from "@tanstack/react-router";
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 type { Client, Group } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor";
import NetworkAssignments from "./NetworkAssignments";
import { ClientDisplayName } from "./clientIdentity";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import ConfigLockIndicator, { lockReason } from "@/features/configuration/ConfigLockIndicator";
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
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).
* it stays live under every authority; deleting a declared row contradicts the
* file and is the one client DELETE the server answers 403 (ruling 7).
*
* The lock fails closed, so it also holds while authority is pending or
* unreachable. There the file is a possibility and not a fact `hand_edited`
* alone cannot say which operator surface set the row so the sentence names
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
*/
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
/** The one note every locked Delete on this page describes itself with. */
const DELETE_LOCK_NOTE_ID = "clients-delete-locked-note";
function declaredDeleteNote(authority: Authority): string {
if (authority.state === "resolved") {
return "This client is declared in the configuration file; remove it there and restart.";
}
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
}
/**
* How the confirmation names the row. The address is always there and always
* unique, so it carries the sentence; a name the operator recognizes leads when
* the row has one.
*/
function clientLabel(client: Client): string {
const name = client.name !== "" ? client.name : client.learned_name;
return name === "" ? client.ip : `${name} (${client.ip})`;
}
const styles = stylex.create({
heading: {
@@ -28,6 +55,24 @@ const styles = stylex.create({
marginTop: "1rem",
color: colors.textMuted,
},
lockNote: {
marginTop: "1rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
filterBar: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
table: {
width: "100%",
minWidth: "48rem",
@@ -53,72 +98,89 @@ const styles = stylex.create({
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",
addressLink: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
actionGroup: {
display: "inline-flex",
alignItems: "center",
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 { group } = useSearch({ from: "/shell/clients" });
const queryClient = useQueryClient();
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
const readOnly = useReadOnlyConfig();
const authority = useAuthority();
// A well-formed id the group list does not contain is a link to a group that
// has since been deleted. It filters to nothing, which is the truth, and the
// notice says so rather than quietly showing every client.
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
// Authority is polled, so it can turn while the confirmation is open. The
// dialog reads it on every render rather than trusting the state that opened
// it, and withdraws the answer that would now fail instead of withdrawing the
// question: the operator is told why, and only they close the dialog.
const deleteLocked = pendingDelete !== null && readOnly && pendingDelete.hand_edited;
// The reason a locked Delete will not answer, printed once above the table.
// Per row it would repeat down the whole page; on the button it was a `title`
// that a keyboard and a touch screen never reached.
const deletesLocked = readOnly && rows.some((client) => client.hand_edited);
return (
<section>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
{group !== undefined && (
<div {...stylex.props(styles.filterBar)}>
<span>
{filterGroup !== undefined
? `Showing clients in ${filterGroup.name}.`
: `No group with id ${group} exists.`}
</span>
<Link to="/clients" search={{}} {...stylex.props(shared.linkButton, shared.focusRing)}>
Clear filter
</Link>
</div>
)}
{/* The whole explanation for this page's locks, printed once. Per row it
would repeat down the table; on the controls it was a `title` that a
keyboard and a touch screen never reached. */}
{readOnly && (
<div {...stylex.props(styles.lockNote)}>
<p>{lockReason(authority)}.</p>
{deletesLocked && <p id={DELETE_LOCK_NOTE_ID}>{declaredDeleteNote(authority)}</p>}
</div>
)}
{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>
) : rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>No clients match this filter.</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)}>Address</th>
<th {...stylex.props(styles.cell)}>Name</th>
<th {...stylex.props(styles.cell)}>Group</th>
<th {...stylex.props(styles.cell)}>First seen</th>
@@ -129,88 +191,55 @@ export default function ClientsPage() {
</tr>
</thead>
<tbody>
{clients.map((client) => (
{rows.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, shared.mono)}>
<Link
to="/clients/$id"
params={{ id: String(client.id) }}
{...stylex.props(styles.addressLink, shared.focusRing)}
>
{client.ip}
</Link>
</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>}
<ClientDisplayName client={client} />
</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)}>
<span {...stylex.props(styles.actionGroup)}>
{/* Naming a client writes configuration, so the affordance is
absent not disabled wherever the write cannot land. */}
{readOnly ? (
<ConfigLockIndicator compact />
) : (
<button
type="button"
onClick={() => setEditing(client)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
{...stylex.props(shared.smallButton, 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>
)}
)}
<button
type="button"
onClick={() => setPendingDelete(client)}
disabled={readOnly && client.hand_edited}
aria-describedby={
readOnly && client.hand_edited ? DELETE_LOCK_NOTE_ID : undefined
}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</span>
</td>
</tr>
))}
@@ -218,9 +247,27 @@ export default function ClientsPage() {
</table>
</div>
)}
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete client"
message={
pendingDelete === null
? ""
: deleteLocked
? declaredDeleteNote(authority)
: `Delete ${clientLabel(pendingDelete)}? Deleted clients re-materialize on their next DNS query.`
}
confirmLabel="Delete"
lock={deleteLocked ? <ConfigLockIndicator /> : undefined}
onConfirm={() => {
if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id);
setPendingDelete(null);
}}
onCancel={() => setPendingDelete(null)}
/>
<InlineError error={deleteMutation.error} />
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
<PrefixesEditor prefixes={prefixes} groups={groups} />
<NetworkAssignments prefixes={prefixes} groups={groups} />
</section>
);
}
@@ -4,18 +4,29 @@ 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 {
firstProblem,
initPrefixEditor,
isDirty,
prefixEditorReducer,
toInputs,
type PrefixEditorAction,
type PrefixProblem,
} from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import AuthorityGate from "@/features/configuration/AuthorityGate";
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[];
}
/** The editor is rendered once per page, so the message can hold a fixed id. */
const VALIDATION_ID = "network-assignments-validation";
const styles = stylex.create({
section: {
marginTop: "2.5rem",
@@ -25,6 +36,13 @@ const styles = stylex.create({
lineHeight: "1.75rem",
fontWeight: 600,
},
headingKey: {
marginLeft: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
@@ -58,6 +76,7 @@ const styles = stylex.create({
width: "5rem",
},
removeButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -80,18 +99,98 @@ const styles = stylex.create({
gap: "0.5rem",
marginTop: "1rem",
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export default function PrefixesEditor({ prefixes, groups }: Props) {
/**
* Address ranges that assign a group to every device inside them.
*
* The section is a configuration rendering, so it goes through the authority
* gate: the editor exists only where a save can land, and file authority gets
* the assignments as a table rather than a form nobody may submit.
*/
export default function NetworkAssignments({ prefixes, groups }: Props) {
return (
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>
Network assignments
<code {...stylex.props(shared.mono, styles.headingKey)}>client_prefixes</code>
</h2>
<p {...stylex.props(styles.intro)}>
An address range assigns its group to every device inside it, for devices with no row of their own. The
highest priority match wins.
</p>
<AuthorityGate>
{(status) =>
status.authority === "database" ? (
<AssignmentsEditor prefixes={prefixes} groups={groups} />
) : (
<AssignmentsTable prefixes={prefixes} />
)
}
</AuthorityGate>
</section>
);
}
function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
if (prefixes.length === 0) return <p {...stylex.props(styles.empty)}>The file declares no network assignments.</p>;
return (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Range</th>
<th {...stylex.props(shared.th)}>Group</th>
<th {...stylex.props(shared.th)}>Priority</th>
</tr>
</thead>
<tbody>
{prefixes.map((prefix) => (
<tr key={prefix.id}>
<td {...stylex.props(shared.td, shared.mono)}>{prefix.prefix}</td>
<td {...stylex.props(shared.td)}>{prefix.group}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{prefix.priority}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* The list is saved as a whole, so the editor holds every row and the PUT
* replaces the set. Reached only under resolved database authority: the gate
* above owns that decision, and no control here consults it a second time.
*/
function AssignmentsEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null);
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<PrefixProblem | 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 }));
// A problem names a row by position, and every action here can move, add or
// delete a position. The message describes the rows Save read, so it dies
// with them rather than drifting onto whatever row inherits the index.
const dispatch = (action: PrefixEditorAction) => {
setValidation(null);
apply(action);
};
/** True for the one input the current message is about; nothing else is marked. */
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
validation !== null && validation.index === index && validation.field === field ? true : undefined;
const save = () => {
const problem = firstProblem(state.rows);
setValidation(problem);
@@ -102,21 +201,18 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
};
return (
<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 {...stylex.props(styles.empty)}>No prefixes configured.</p>
<p {...stylex.props(styles.empty)}>No network assignments configured.</p>
) : (
<ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => (
<li key={index} {...stylex.props(styles.row)}>
<input
type="text"
aria-label={`Prefix ${index + 1}`}
aria-label={`Range ${index + 1}`}
aria-invalid={invalid(index, "prefix")}
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
placeholder="192.168.1.0/24"
value={row.prefix}
onChange={(event) =>
@@ -125,7 +221,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/>
<Select
aria-label={`Group for prefix ${index + 1}`}
aria-label={`Group for range ${index + 1}`}
variant="inline"
value={String(row.group_id)}
onChange={(value) =>
@@ -136,7 +232,9 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
<input
type="text"
inputMode="numeric"
aria-label={`Priority for prefix ${index + 1}`}
aria-label={`Priority for range ${index + 1}`}
aria-invalid={invalid(index, "priority")}
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
placeholder="100"
value={row.priority}
onChange={(event) =>
@@ -156,8 +254,8 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
</ul>
)}
{validation !== null && (
<p role="alert" {...stylex.props(styles.validation)}>
{validation}
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
{validation.message}
</p>
)}
<InlineError error={mutation.error} />
@@ -167,30 +265,26 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
{...stylex.props(shared.button, shared.focusRing)}
>
Add prefix
Add range
</button>
<button
type="button"
onClick={save}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
disabled={!dirty || mutation.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save prefixes
Save assignments
</button>
{dirty && (
<button
type="button"
onClick={() => {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
onClick={() => dispatch({ type: "reset", prefixes })}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard changes
</button>
)}
</div>
</section>
</>
);
}
@@ -0,0 +1,72 @@
/**
* How a client says who it is, shared by the list and the detail page.
*
* The learned marker lives here and nowhere else. A client is named once on
* each of these two pages, so the tag is information; the query tables render
* the same muted name through `ClientName` without it, because repeating the
* tag down every row of a log is noise.
*/
import * as stylex from "@stylexjs/stylex";
import type { Client } from "@/lib/types";
import type { Authority } from "@/features/configuration/authority";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
unnamed: {
color: colors.textMuted,
},
});
export function ClientDisplayName({ client }: { client: Client }) {
if (client.name !== "") return <>{client.name}</>;
if (client.learned_name !== "") {
return (
<span {...stylex.props(shared.learnedName)}>
{client.learned_name}
<span {...stylex.props(shared.learnedTag)}>learned</span>
</span>
);
}
return <span {...stylex.props(styles.unnamed)}></span>;
}
export interface Provenance {
label: string;
detail: string;
}
/**
* Where this row's name and group came from.
*
* `hand_edited` records that an operator settled this client, but not which
* operator surface did: under file authority the reconciler sets it from the
* declaration, and in database mode the admin's own edit does. Authority is the
* only way to tell them apart, so while it is pending or unreachable the page
* says what it knows and names the doubt rather than picking one.
*/
export function provenanceOf(client: Client, authority: Authority): Provenance {
if (!client.hand_edited) {
return {
label: "Learned",
detail: "This client appeared from DNS traffic. Its name, if any, comes from reverse DNS.",
};
}
if (authority.state === "resolved" && authority.status.authority === "managed_file") {
return {
label: "Declared",
detail: `Declared in ${authority.status.path ?? "the configuration file"}. Reverse DNS does not overwrite it.`,
};
}
if (authority.state === "resolved") {
return {
label: "Edited",
detail: "The name and group were set here. Reverse DNS does not overwrite them.",
};
}
return {
label: "Set by hand",
detail: "Either declared in the configuration file or edited here — nxdns cannot say which until it reports its configuration status.",
};
}
+13 -3
View File
@@ -16,6 +16,14 @@ import * as stylex from "@stylexjs/stylex";
import { clientsQuery } from "@/lib/queries";
import type { Client } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
/** Secondary to the name it qualifies, and never the only thing in the cell. */
address: {
color: colors.textMuted,
},
});
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
@@ -53,11 +61,13 @@ export function clientLabel(ip: string, names: ClientNames): { text: string; lea
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.
// The name replaces the address, so the address follows it as real text that
// anyone can read and copy. A `title` carried it before, which reaches
// neither a keyboard nor a touch screen.
return (
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
<span {...stylex.props(label.learned && shared.learnedName)}>
{label.text}
<span {...stylex.props(styles.address)}> ({ip})</span>
</span>
);
}
@@ -76,11 +76,15 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
test("firstProblem flags empty prefixes and non-integer priorities", () => {
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toEqual({
index: 0,
field: "prefix",
message: "Row 1: prefix is required.",
});
expect(
firstProblem([
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
]),
).toBe("Row 2: priority must be a whole number.");
).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
});
+13 -4
View File
@@ -55,11 +55,20 @@ export function isDirty(state: PrefixEditorState): boolean {
});
}
export function firstProblem(rows: PrefixRow[]): string | null {
for (const [i, row] of rows.entries()) {
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
/** Which input the message is about, so the editor can point that input at it. */
export interface PrefixProblem {
index: number;
field: "prefix" | "priority";
message: string;
}
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
for (const [index, row] of rows.entries()) {
if (row.prefix.trim() === "")
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
const priority = row.priority.trim();
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
if (priority !== "" && !/^\d+$/.test(priority))
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
}
return null;
}
+129
View File
@@ -0,0 +1,129 @@
/**
* The Clients pages under a real router: both of them read the same list, and
* the detail route is reached by deep link as often as by click, so the tests
* drive the router rather than the components.
*/
import { act, render, screen } from "@testing-library/react";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queries";
import { createAppRouter } from "@/routes";
export const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
export const CLIENTS = {
clients: [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "laptop-1.lan",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "kids-tablet.lan",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
],
};
export const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
export const DATABASE = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
export const MANAGED_FILE = {
authority: "managed_file",
path: "/etc/nxdns/config.zon",
reconciled_at: 1754899200,
restart_pending: false,
};
export const BASE: Record<string, unknown> = {
"GET /api/clients": CLIENTS,
"GET /api/client-prefixes": PREFIXES,
"GET /api/groups": GROUPS,
"GET /api/version": VERSION,
"GET /api/config/status": DATABASE,
};
/** A request that never settles, so its query stays pending for the whole test. */
export const NEVER = Symbol("never");
export function stubFetch(map: Record<string, unknown>): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === NEVER) return new Promise<Response>(() => {});
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
export function renderAt(path: string, map: Record<string, unknown>) {
const fetchMock = stubFetch(map);
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { router, fetchMock, queryClient };
}
export async function renderClientsPage(map: Record<string, unknown> = BASE) {
const handles = renderAt("/clients", map);
await screen.findByRole("heading", { name: "Clients" });
return handles;
}
/**
* Turns configuration authority under a mounted page, the way the status
* query's own poll would: the stub reads the map on each call, so replacing the
* entry and refetching is the whole transition. `undefined` stands for a failed
* status the stub answers 404 for a key it does not hold.
*/
export async function setConfigStatus(
map: Record<string, unknown>,
queryClient: QueryClient,
status: unknown,
): Promise<void> {
map["GET /api/config/status"] = status;
await act(async () => {
await queryClient.refetchQueries({ queryKey: queryKeys.configStatus });
// The cache lands before its observers are notified — react-query defers
// that notification — so the render pass needs one more turn inside act.
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import type { ConfigStatus } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { useAuthority } from "./authority";
import { styles } from "./styles";
/**
* Guards a configuration rendering on resolved authority (D6).
*
* The two renderings editable forms and file-mode definition lists are
* mutually exclusive answers to a question only the server can settle, so
* neither is drawn until it has. Pending is a skeleton; a failed status query
* is an error with a Retry, never a form drawn on a guess.
*
* Runtime actions do not pass through here. Pause, update now and reload
* certificates work under every authority, so their pages render them beside
* the gate rather than inside it.
*/
export default function AuthorityGate({ children }: { children: (status: ConfigStatus) => ReactNode }) {
const authority = useAuthority();
if (authority.state === "pending") {
return (
<p role="status" {...stylex.props(styles.pending, shared.pulse)}>
Checking which configuration source this server obeys
</p>
);
}
if (authority.state === "failed") {
return (
<div {...stylex.props(styles.blocked)}>
<p>
Configuration status unavailable. nxdns cannot say whether a file or the database owns this
configuration, so nothing here can be edited until it answers.
</p>
<InlineError error={authority.error} onRetry={authority.retry} />
</div>
);
}
return <>{children(authority.status)}</>;
}
@@ -0,0 +1,40 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority";
const styles = stylex.create({
line: {
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
path: {
overflowWrap: "anywhere",
},
});
/**
* File authority, stated once, in the configuration sub-navigation (§File
* mode). Not a banner on every route: the fact belongs to configuration, and
* repeating it above Overview and Activity buys nothing.
*
* "loaded" is deliberate. `reconciled_at` is when this process read the file;
* the server cannot prove the file still says what it said then, so the line
* never claims to describe the file's current contents.
*/
export default function AuthorityLine() {
const authority = useAuthority();
if (authority.state !== "resolved" || authority.status.authority !== "managed_file") return null;
const { path, reconciled_at } = authority.status;
return (
<p {...stylex.props(styles.line)}>
{"File-managed · "}
<code {...stylex.props(shared.mono, styles.path)}>{path}</code>
{reconciled_at === null ? null : ` · loaded ${formatTime(reconciled_at)}`}
</p>
);
}
@@ -10,9 +10,7 @@ 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} readOnly={false} error={null} onSubmit={rejecting} onCancel={undefined} />,
);
const { rerender } = render(<BlocklistForm busy={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" } });
@@ -24,7 +22,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} readOnly={false} error={null} onSubmit={resolving} onCancel={undefined} />);
rerender(<BlocklistForm busy={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("");
@@ -4,7 +4,6 @@ import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const styles = stylex.create({
form: {
@@ -56,14 +55,12 @@ 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, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [name, setName] = useState(initial?.name ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -122,12 +119,7 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
Enabled
</label>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
<button type="submit" disabled={busy} {...stylex.props(shared.primaryButton, shared.focusRing)}>
{initial === undefined ? "Add source" : "Save changes"}
</button>
{onCancel !== undefined && (
@@ -0,0 +1,80 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import DefinitionList from "@/ui/DefinitionList";
import ConfigLockIndicator from "./ConfigLockIndicator";
import { CONFIG_PATH, DATABASE, MANAGED_FILE, stubApi } from "./testFixtures";
import type { ConfigStatus } from "@/lib/types";
/**
* The two shared pieces the redesign adds: the file-mode rendering of a scalar
* and the compact lock a configuration control outside a configuration page
* carries.
*/
afterEach(() => {
vi.unstubAllGlobals();
});
function renderIndicator(status: ConfigStatus | "failed") {
if (status === "failed") {
stubApi(DATABASE, {
responses: {
"GET /api/config/status": new Response(JSON.stringify({ error: "unavailable" }), { status: 503 }),
},
});
} else {
stubApi(status);
}
render(
<QueryClientProvider client={createQueryClient()}>
<ConfigLockIndicator />
</QueryClientProvider>,
);
}
test("a definition names the value in words and the file key beside it", () => {
render(
<DefinitionList
items={[
{ label: "Port", zonKey: "dns.port", value: "53" },
{ label: "Authentication", value: "required" },
]}
/>,
);
const port = screen.getByText("Port");
expect(port.tagName).toBe("DT");
expect(port.textContent).toBe("Portdns.port");
// A derived value has no key: inventing one would send the reader looking
// for a line that is not in the file.
expect(screen.getByText("Authentication").textContent).toBe("Authentication");
expect(screen.getByText("required").tagName).toBe("DD");
});
test("the lock is silent when the database owns the configuration", async () => {
renderIndicator(DATABASE);
await waitFor(() => expect(screen.queryByText("Locked")).toBeNull());
});
test("under file authority the lock names the file, in words a reader hears", async () => {
renderIndicator(MANAGED_FILE);
// The reason is visible text beside the tag, not a title and not a label: a
// tooltip reaches neither a keyboard nor a touch screen, and screen-reader-only
// text is the same failure pointed the other way.
const lock = await screen.findByText("Locked");
await waitFor(() =>
expect(screen.getByText(`Managed by ${CONFIG_PATH}; edit the file and restart nxdns`)).toBeTruthy(),
);
expect(lock.getAttribute("title")).toBeNull();
expect(lock.getAttribute("aria-label")).toBeNull();
});
test("an unanswered status still locks, and says that is why", async () => {
renderIndicator("failed");
await screen.findByText("Locked");
await waitFor(() => expect(screen.getByText(/^Configuration status unavailable/)).toBeTruthy());
});
@@ -0,0 +1,71 @@
import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority, type Authority } from "./authority";
const styles = stylex.create({
row: {
display: "inline-flex",
alignItems: "baseline",
flexWrap: "wrap",
gap: "0.375rem",
},
/** The sentence is secondary to the word, and wraps rather than stretching a row. */
reason: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tag: {
marginLeft: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
borderRadius: "0.25rem",
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
/**
* The compact lock, for a configuration control that survives outside a
* configuration page the Clients page edits a group assignment the file may
* own. The configuration pages themselves do not use it: they change rendering
* rather than annotate a control they left up.
*
* The word is real text, not colour or an icon, so a screen reader announces
* the reason the control will not answer.
*/
/**
* Why a configuration control will not answer. Exported because a page that
* shows the compact tag has to print this sentence itself, once, somewhere the
* tag can point at.
*/
export function lockReason(authority: Authority): string {
if (authority.state === "pending") return "Checking which configuration source this server obeys";
if (authority.state === "failed") return "Configuration status unavailable, so edits are held back";
return `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
}
export default function ConfigLockIndicator({ compact = false }: { compact?: boolean }) {
const authority = useAuthority();
if (authority.state === "resolved" && authority.status.authority === "database") return null;
const reason = lockReason(authority);
// A compact caller has no room for the sentence and must print it once
// nearby instead: the Clients table would otherwise repeat it down every row.
if (compact) return <span {...stylex.props(styles.tag)}>Locked</span>;
// Everywhere else the reason is visible text rather than a `title` or an
// `aria-label`. A tooltip reaches neither a keyboard nor a touch screen, and
// screen-reader-only text is the same failure pointed the other way.
return (
<span {...stylex.props(styles.row)}>
<span {...stylex.props(styles.tag)}>Locked</span>
<span {...stylex.props(styles.reason)}>{reason}</span>
</span>
);
}
@@ -0,0 +1,17 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { styles } from "./styles";
/**
* The short note every file-managed page carries: where the values come from,
* and what applies a change to them. It replaces the disabled Save button
* the reader needs the path, not a control that cannot work.
*/
export default function FileModeNote({ path }: { path: string | null }) {
return (
<p {...stylex.props(styles.fileNote)}>
These values are loaded from <code {...stylex.props(shared.mono)}>{path ?? "the configuration file"}</code>.
Edit that file to change them; most changes need an nxdns restart to take effect.
</p>
);
}
@@ -3,11 +3,11 @@ 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 { sameSet } from "./sourceSet";
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
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";
interface Props {
groupId: number;
@@ -29,13 +29,6 @@ const styles = stylex.create({
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",
@@ -48,7 +41,6 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const sources = useQuery(groupSourcesQuery(groupId));
const mutation = useMutation(groupSourcesPutMutation(queryClient));
const [selected, setSelected] = useState<number[] | null>(null);
const readOnly = useReadOnlyConfig();
if (sources.isPending) {
return (
@@ -60,7 +52,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isError) return <InlineError error={sources.error} />;
if (blocklists.length === 0) {
return <p {...stylex.props(styles.note)}>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 Sources tab.</p>;
}
const current = selected ?? sources.data;
@@ -68,27 +60,27 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
return (
<div {...stylex.props(styles.root)}>
<ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))}
{...stylex.props(shared.focusRing)}
/>
{blocklist.name}
</label>
</li>
))}
</ul>
{/* The section's own "Assigned sources" heading is the visible label; a
Label here would put the same words on screen twice. Ids cross the
React Aria boundary as strings, the same convention as Select. */}
<CheckboxGroup
aria-label="Assigned sources"
value={current.map(String)}
onChange={(values) => setSelected(values.map(Number).sort((a, b) => a - b))}
>
<ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
</li>
))}
</ul>
</CheckboxGroup>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.buttonRow)}>
<button
type="button"
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
disabled={!dirty || mutation.isPending}
onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
}
@@ -0,0 +1,79 @@
import * as stylex from "@stylexjs/stylex";
import type { ForwardZone, LocalRecord } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { styles as config } from "./styles";
/**
* Local records and forward zones under file authority. Collections are
* tables, not definition lists, and each names the ZON key it comes from once
* rather than repeating it on every row.
*/
export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
return (
<section {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>
Local records
<code {...stylex.props(shared.mono, config.panelKey)}>local_records</code>
</h2>
{records.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no local records.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>Type</th>
<th {...stylex.props(shared.th)}>Value</th>
<th {...stylex.props(shared.th)}>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.id}>
<td {...stylex.props(shared.td, shared.mono)}>{record.name}</td>
<td {...stylex.props(shared.td)}>{record.rtype}</td>
<td {...stylex.props(shared.td, shared.mono)}>{record.value}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{record.ttl}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
return (
<section {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>
Forward zones
<code {...stylex.props(shared.mono, config.panelKey)}>forward_zones</code>
</h2>
{zones.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no forward zones.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Zone</th>
<th {...stylex.props(shared.th)}>Resolver</th>
</tr>
</thead>
<tbody>
{zones.map((zone) => (
<tr key={zone.id}>
<td {...stylex.props(shared.td, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(shared.td, shared.mono)}>{zone.resolver}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
@@ -0,0 +1,584 @@
import { useEffect, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
blocklistsQuery,
clientsQuery,
groupCreateMutation,
groupDeleteMutation,
groupSourcesQuery,
groupUpdateMutation,
groupsQuery,
ruleCreateMutation,
ruleDeleteMutation,
rulesQuery,
} from "@/lib/queries";
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import DefinitionList from "@/ui/DefinitionList";
import Select from "@/ui/Select";
import Switch from "@/ui/Switch";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import GroupSourcesEditor from "./GroupSourcesEditor";
import QueryPanel from "./QueryPanel";
import { styles as config } from "./styles";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
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({
createForm: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.75rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
detailHeading: {
fontSize: "1.25rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
controlRow: {
marginTop: "0.75rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
spacer: {
marginLeft: "auto",
},
destructive: {
color: colors.danger,
},
ruleForm: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
pattern: {
fontWeight: 500,
},
});
export default function ProtectionGroups() {
const groups = useQuery(groupsQuery());
return (
<AuthorityGate>
{(status) => (
<div>
{status.authority === "managed_file" && <FileModeNote path={status.path} />}
<QueryPanel query={groups}>
{(rows) => <GroupsMasterDetail groups={rows} status={status} />}
</QueryPanel>
</div>
)}
</AuthorityGate>
);
}
/**
* Master/detail on one group at a time. The selection is `?group=`, so a view
* of a group is a link and the browser's back button walks the groups the
* reader looked at.
*/
function GroupsMasterDetail({ groups, status }: { groups: Group[]; status: ConfigStatus }) {
const search = useSearch({ from: "/shell/configuration/protection" });
const navigate = useNavigate({ from: "/configuration/protection" });
const known = groups.some((group) => group.id === search.group);
const selectedId = known ? search.group : groups[0]?.id;
const selected = groups.find((group) => group.id === selectedId);
// An id the URL named that no group has — deleted, or hand-typed — falls
// back to the first group, and the URL is rewritten to say so. `replace`,
// because a corrected address is not a place the reader chose to be and a
// back button that returns to it would trap them.
useEffect(() => {
if (selectedId === undefined || selectedId === search.group) return;
void navigate({ search: { tab: search.tab, group: selectedId }, replace: true });
}, [navigate, search.group, search.tab, selectedId]);
const fileMode = status.authority === "managed_file";
return (
<div {...stylex.props(config.split)}>
<div>
{!fileMode && <CreateGroupForm />}
<nav aria-label="Groups" {...stylex.props(styles.controlRow)}>
<ul {...stylex.props(config.masterList)}>
{groups.map((group) => (
<li key={group.id}>
<Link
to="/configuration/protection"
search={{ tab: search.tab, group: group.id }}
activeOptions={{ includeSearch: true }}
{...stylex.props(
config.masterLink,
group.id === selectedId && config.masterLinkActive,
shared.focusRing,
)}
>
{group.name}
</Link>
</li>
))}
</ul>
</nav>
</div>
{selected === undefined ? (
<p {...stylex.props(config.empty)}>No groups exist.</p>
) : fileMode ? (
<GroupDetailReadOnly group={selected} />
) : (
<GroupDetailEditable group={selected} />
)}
</div>
);
}
function CreateGroupForm() {
const queryClient = useQueryClient();
const create = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
return (
<>
<form
{...stylex.props(styles.createForm)}
onSubmit={(event: FormEvent) => {
event.preventDefault();
const name = newName.trim();
if (name === "") return;
create.mutate({ name }, { onSuccess: () => setNewName("") });
}}
>
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
New group
</label>
<input
id="new-group-name"
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
{...stylex.props(shared.smallInput, shared.focusRing)}
/>
<button
type="submit"
disabled={create.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Create
</button>
</form>
<InlineError error={create.error} />
</>
);
}
function ClientCountLink({ group }: { group: Group }) {
const clients = useQuery(clientsQuery());
const count = clients.data?.filter((client) => client.group_id === group.id).length;
return (
<p {...stylex.props(config.note)}>
<Link to="/clients" search={{ group: group.id }} {...stylex.props(shared.focusRing)}>
{count === undefined
? "Clients in this group"
: `${count} client${count === 1 ? "" : "s"} in this group`}
</Link>
</p>
);
}
function GroupDetailReadOnly({ group }: { group: Group }) {
return (
<div>
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
<section {...stylex.props(config.panel)}>
<DefinitionList
items={[
{ label: "Name", zonKey: "groups[].name", value: group.name },
{ label: "Safe search", zonKey: "groups[].safe_search", value: String(group.safe_search) },
]}
/>
</section>
<GroupSourcesReadOnly group={group} />
<GroupRules group={group} editable={false} />
<ClientCountLink group={group} />
</div>
);
}
function GroupSourcesReadOnly({ group }: { group: Group }) {
const blocklists = useQuery(blocklistsQuery());
const assigned = useQuery(groupSourcesQuery(group.id));
return (
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>
Assigned sources
<code {...stylex.props(shared.mono, config.panelKey)}>group_sources</code>
</h3>
<QueryPanel query={assigned}>
{(sourceIds) => (
<QueryPanel query={blocklists}>
{(catalogue) => <AssignedSources sourceIds={sourceIds} catalogue={catalogue} />}
</QueryPanel>
)}
</QueryPanel>
</section>
);
}
function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) {
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
if (assigned.length === 0) return <p {...stylex.props(config.empty)}>This group is assigned no sources.</p>;
return (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Source</th>
<th {...stylex.props(shared.th)}>URL</th>
</tr>
</thead>
<tbody>
{assigned.map((source) => (
<tr key={source.id}>
<td {...stylex.props(shared.td)}>{source.name}</td>
<td {...stylex.props(shared.td, shared.mono)}>{source.url}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function GroupDetailEditable({ group }: { group: Group }) {
const queryClient = useQueryClient();
const blocklists = useQuery(blocklistsQuery());
const update = useMutation(groupUpdateMutation(queryClient));
const remove = useMutation(groupDeleteMutation(queryClient));
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(group.name);
const [confirming, setConfirming] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID;
return (
<div>
{renaming ? (
<form
{...stylex.props(styles.controlRow)}
onSubmit={(event: FormEvent) => {
event.preventDefault();
const trimmed = name.trim();
if (trimmed === "") return;
update.mutate(
{ id: group.id, input: { name: trimmed, safe_search: group.safe_search } },
{ onSuccess: () => setRenaming(false) },
);
}}
>
<input
type="text"
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
{...stylex.props(shared.smallInput, shared.focusRing)}
autoFocus
/>
<button
type="submit"
disabled={update.isPending}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Save
</button>
<button
type="button"
onClick={() => {
setName(group.name);
setRenaming(false);
}}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
</button>
</form>
) : (
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
)}
<div {...stylex.props(styles.controlRow)}>
<Switch
isSelected={group.safe_search}
isDisabled={update.isPending}
onChange={(safeSearch) =>
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
}
>
Safe search
</Switch>
<span {...stylex.props(styles.spacer)}>
{!renaming && (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => {
setName(group.name);
setRenaming(true);
}}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Rename group
</button>
)}{" "}
<button
type="button"
disabled={isDefault || remove.isPending}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => setConfirming(true)}
{...stylex.props(shared.smallButton, styles.destructive, shared.focusRing)}
>
Delete group
</button>
</span>
</div>
{isDefault && <p {...stylex.props(config.note)}>{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={update.error ?? remove.error} />
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>Assigned sources</h3>
<QueryPanel query={blocklists}>
{(catalogue) => <GroupSourcesEditor groupId={group.id} blocklists={catalogue} />}
</QueryPanel>
</section>
<GroupRules group={group} editable />
<ClientCountLink group={group} />
<ConfirmDialog
isOpen={confirming}
title="Delete group"
message={`Delete group "${group.name}"? Its clients fall back to the default group.`}
confirmLabel="Delete"
onConfirm={() => {
setConfirming(false);
remove.mutate(group.id);
}}
onCancel={() => setConfirming(false)}
/>
</div>
);
}
/**
* The rules that apply to the selected group, and nothing else. Rules are
* always scoped to a group, so the group-centred page is the only place they
* need to be read: a flat list of every rule in the household was a list of
* facts about no particular policy.
*/
function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
const rules = useQuery(rulesQuery());
return (
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>
Rules
{!editable && <code {...stylex.props(shared.mono, config.panelKey)}>rules</code>}
</h3>
<QueryPanel query={rules}>
{(all) => {
const scoped = all.filter((rule) => rule.group_id === group.id);
return (
<>
{scoped.length === 0 ? (
<p {...stylex.props(config.empty)}>No allow or block rules for this group.</p>
) : (
<RulesTable rules={scoped} editable={editable} />
)}
{editable && <CreateRuleForm group={group} />}
</>
);
}}
</QueryPanel>
</section>
);
}
function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
const queryClient = useQueryClient();
const remove = useMutation(ruleDeleteMutation(queryClient));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
return (
<>
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.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)}>Created</th>
{editable && (
<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)}>{formatTime(rule.created_at)}</td>
{editable && (
<td {...stylex.props(shared.td)}>
<button
type="button"
onClick={() => setPendingDelete(rule)}
disabled={remove.isPending}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<InlineError error={remove.error} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={() => {
if (pendingDelete !== null) remove.mutate(pendingDelete.id);
setPendingDelete(null);
}}
onCancel={() => setPendingDelete(null)}
/>
</>
);
}
function CreateRuleForm({ group }: { group: Group }) {
const queryClient = useQueryClient();
const create = useMutation(ruleCreateMutation(queryClient));
const [pattern, setPattern] = useState("");
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
function onSubmit(event: FormEvent) {
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: group.id, pattern: sent, kind, action }, { onSuccess: () => setPattern("") });
}
return (
<form onSubmit={onSubmit} {...stylex.props(styles.ruleForm)}>
<h4 {...stylex.props(styles.fieldLabel)}>Create rule in {group.name}</h4>
<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}
/>
</div>
<div>
<button
type="submit"
disabled={create.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
</div>
<InlineError error={create.error} />
</form>
);
}
@@ -0,0 +1,342 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
/**
* Protection in database mode: the group-centred master/detail, the rules
* scoped to the selected group, and the shared source catalogue with its one
* runtime action.
*/
let calls: Call[];
afterEach(() => {
vi.unstubAllGlobals();
});
async function openProtection(group?: number) {
calls = stubApi(DATABASE);
const suffix = group === undefined ? "" : `?group=${group}`;
return renderPage(`/configuration/protection${suffix}`, "Protection");
}
function writes(method: string): Call[] {
return calls.filter((call) => call.method === method);
}
test("the group list is the master, and the selected group is the detail", async () => {
await openProtection();
const list = within(screen.getByRole("navigation", { name: "Groups" }));
expect(list.getByRole("link", { name: "default" })).toBeTruthy();
expect(list.getByRole("link", { name: "kids" })).toBeTruthy();
await screen.findByRole("heading", { name: "default", level: 2 });
});
test("the default group cannot be renamed or deleted, and says why", async () => {
await openProtection(1);
await screen.findByRole("heading", { name: "default", level: 2 });
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "Delete group" }) as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText("The default group cannot be renamed or deleted.")).toBeTruthy();
});
test("another group can be renamed and deleted, and carries its safe-search state", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete group "kids"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes("DELETE")).toEqual([]);
});
test("toggling safe search resends the whole group row", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({
url: "/api/groups/2",
body: { name: "kids", safe_search: false },
});
});
test("the source assignment saves the full set via PUT", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
const ads = (await screen.findByRole("checkbox", { name: "Ads" })) as HTMLInputElement;
expect(ads.checked).toBe(false);
const save = screen.getByRole("button", { name: "Save sources" }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(ads);
expect(save.disabled).toBe(false);
fireEvent.click(save);
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({
url: "/api/groups/2/sources",
body: { source_ids: [1] },
});
});
test("the source checkboxes form one named group, and each carries its own state", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
// The section heading names the group; the boxes belong to it rather than
// sitting loose beside the group's other checkboxes.
const group = await screen.findByRole("group", { name: "Assigned sources" });
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
expect(ads.checked).toBe(false);
// Safe search is the group's own field, not one of its sources, and it is a
// switch rather than a checkbox because it applies the moment it moves.
expect(within(group).queryByRole("switch")).toBeNull();
expect(screen.getByRole("switch", { name: "Safe search" })).toBeTruthy();
fireEvent.click(ads);
await waitFor(() => expect(ads.checked).toBe(true));
// Discard returns the group to the server's set rather than clearing it.
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
await waitFor(() =>
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
);
expect(writes("PUT")).toEqual([]);
});
test("only the selected group's rules are listed", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
expect(await screen.findByText("*.social.example")).toBeTruthy();
expect(screen.queryByText("ads.example.com")).toBeNull();
});
test("a new rule is created in the selected group, with the pattern posted verbatim for a regex", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });
// An exact pattern is trimmed; a regex is stored and matched byte for byte.
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(writes("POST")).toHaveLength(1));
expect(writes("POST")[0]?.body).toEqual({
group_id: 2,
pattern: "ads.example.net",
kind: "exact",
action: "block",
});
});
test("a rate-limited rule create shows the countdown from Retry-After", async () => {
calls = stubApi(DATABASE, {
onWrite: (call) =>
call.url === "/api/rules"
? new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "12" },
})
: null,
});
await renderPage("/configuration/protection?group=2", "Protection");
await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 12s.");
});
test("cancelling the rule delete confirmation leaves the rule alone", async () => {
await openProtection(2);
await screen.findByText("*.social.example");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes("DELETE")).toEqual([]);
expect(screen.getByText("*.social.example")).toBeTruthy();
});
test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
await openProtection(2);
const pattern = await screen.findByLabelText("Pattern");
expect(pattern.getAttribute("autocapitalize")).toBe("none");
expect(pattern.getAttribute("autocorrect")).toBe("off");
expect(pattern.getAttribute("spellcheck")).toBe("false");
});
test("the kind selector offers the three contract kinds and can pick regex", async () => {
await openProtection(2);
await screen.findByLabelText("Pattern");
fireEvent.click(screen.getByRole("button", { name: /Kind$/ }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["exact", "wildcard", "regex"]);
fireEvent.click(screen.getByRole("option", { name: "regex" }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ^ad[0-9]+- " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(writes("POST")).toHaveLength(1));
expect(writes("POST")[0]?.body).toMatchObject({ pattern: " ^ad[0-9]+- ", kind: "regex" });
});
test("deleting a rule asks first, then issues the DELETE", async () => {
await openProtection(2);
await screen.findByText("*.social.example");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete the block rule for "*.social.example"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes("DELETE")).toHaveLength(1));
expect(writes("DELETE")[0]?.url).toBe("/api/rules/2");
});
test("the client count links into Clients filtered by the group (D3)", async () => {
await openProtection(2);
const link = await screen.findByRole("link", { name: "1 client in this group" });
expect(link.getAttribute("href")).toBe("/clients?group=2");
});
test("a group can be created from the master column", async () => {
await openProtection();
fireEvent.change(await screen.findByLabelText("New group"), { target: { value: " guests " } });
fireEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() => expect(writes("POST")).toHaveLength(1));
expect(writes("POST")[0]).toMatchObject({ url: "/api/groups", body: { name: "guests" } });
});
test("the Sources tab lists the catalogue with both skipped columns and their note", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
expect(await screen.findByText("Ads")).toBeTruthy();
expect(screen.getByText("Trackers")).toBeTruthy();
expect(screen.getByText("Suggested")).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();
// Switches, not checkboxes: the row applies the moment it moves, and the role
// is what tells a screen reader so.
expect((screen.getByRole("switch", { name: "Ads enabled" }) as HTMLInputElement).checked).toBe(true);
expect((screen.getByRole("switch", { name: "Trackers enabled" }) as HTMLInputElement).checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
});
test("a source switch resends the whole row immediately, with no Save step", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
fireEvent.click(await screen.findByRole("switch", { name: "Trackers enabled" }));
// No Save button stands between the switch and the write: one click, one PUT.
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({
url: "/api/blocklists/2",
// The whole row goes back, not a patch of the one field that moved.
body: {
url: "https://example.com/trackers.txt",
name: "Trackers",
enabled: true,
is_suggested: true,
},
});
});
test("Update now says it started, and says nothing once it succeeds", async () => {
let release: ((response: Response) => void) | null = null;
calls = stubApi(DATABASE, {
onWrite: (call) =>
// Held open so the started state is observable, not a frame that resolves
// before the assertion.
call.url === "/api/blocklists/update"
? new Promise<Response>((resolve) => {
release = resolve;
})
: null,
});
await renderPage("/configuration/protection?tab=sources", "Protection");
fireEvent.click(await screen.findByRole("button", { name: "Update now" }));
const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement;
expect(pending.disabled).toBe(true);
expect(screen.getByRole("status").textContent).toBe("Update started…");
release!(
new Response(JSON.stringify({ sources: [] }), {
status: 202,
headers: { "content-type": "application/json" },
}),
);
await waitFor(() => expect(screen.getByRole("button", { name: "Update now" })).toBeTruthy());
// Success leaves no standing claim behind: the refreshed counters are the
// signal, and a "counters refreshed" line would outlive a failed refetch.
expect(screen.queryByRole("status")).toBeNull();
});
test("a rate-limited Update now shows the countdown from Retry-After", async () => {
calls = stubApi(DATABASE, {
onWrite: (call) =>
call.url === "/api/blocklists/update"
? new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "7" },
})
: null,
});
await renderPage("/configuration/protection?tab=sources", "Protection");
fireEvent.click(await screen.findByRole("button", { name: "Update now" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
});
test("deleting a source asks first, then issues the DELETE for that source", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText("Ads");
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "Trackers"? Its domains stop being blocked.');
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes("DELETE")).toHaveLength(1));
expect(writes("DELETE")[0]?.url).toBe("/api/blocklists/2");
});
test("cancelling the source delete confirmation leaves the source alone", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText("Ads");
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
const dialog = await screen.findByRole("alertdialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes("DELETE")).toEqual([]);
expect(screen.getByText("Trackers")).toBeTruthy();
});
@@ -0,0 +1,38 @@
import { useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import Tabs from "@/ui/Tabs";
import ProtectionGroups from "./ProtectionGroups";
import ProtectionSources from "./ProtectionSources";
import { styles } from "./styles";
import type { ProtectionTab } from "./search";
/**
* Protection: what policy governs each group, and which rules and lists
* produce it. Group-centred, because a rule or a source only means something
* once you know whose queries it applies to.
*/
export default function ProtectionPage() {
const search = useSearch({ from: "/shell/configuration/protection" });
const tab = search.tab ?? "groups";
const navigate = useNavigate({ from: "/configuration/protection" });
return (
<section>
<h1 {...stylex.props(styles.heading)}>Protection</h1>
<p {...stylex.props(styles.intro)}>What each group of clients is allowed to resolve, and why.</p>
<Tabs
label="Protection"
selectedKey={tab}
// The group rides along, so switching tabs and coming back returns to
// the group the reader was looking at rather than the first one.
onSelectionChange={(key) =>
void navigate({ search: { tab: key as ProtectionTab, group: search.group } })
}
tabs={[
{ id: "groups", label: "Groups", content: <ProtectionGroups /> },
{ id: "sources", label: "Sources", content: <ProtectionSources /> },
]}
/>
</section>
);
}
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
@@ -11,48 +11,24 @@ import {
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";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate";
import BlocklistForm from "./BlocklistForm";
import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel";
import { styles as config } from "./styles";
const SKIPPED_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.";
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,
},
@@ -82,9 +58,129 @@ const styles = stylex.create({
},
});
export default function BlocklistsPage() {
/**
* A runtime action: it re-downloads the sources the running process already
* knows about, so it stays enabled under file authority and while
* `/api/config/status` is still answering.
*
* The feedback is the action's own, and it is transient: started while the
* request is in flight, the failure verbatim if it fails, and nothing on
* success the refreshed counters are the success signal, and a standing
* "counters refreshed" line would claim a refetch that may itself have failed.
* Durable per-source outcomes live in Diagnostics.
*/
function UpdateNowAction() {
const queryClient = useQueryClient();
const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient));
return (
<div {...stylex.props(config.actionRow)}>
<button
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
{updateNow.isPending && (
<p role="status" {...stylex.props(config.note)}>
Update started
</p>
)}
<InlineError error={updateNow.error} />
</div>
);
}
export default function ProtectionSources() {
const blocklists = useQuery(blocklistsQuery());
return (
<div>
<p {...stylex.props(config.intro)}>
The shared catalogue every group draws from. A group subscribes to sources on the Groups tab.
</p>
<UpdateNowAction />
<AuthorityGate>
{(status) =>
status.authority === "managed_file" ? (
<>
<FileModeNote path={status.path} />
<QueryPanel query={blocklists}>
{(rows) => <SourcesReadOnly blocklists={rows} />}
</QueryPanel>
</>
) : (
<QueryPanel query={blocklists}>{(rows) => <SourcesEditor blocklists={rows} />}</QueryPanel>
)
}
</AuthorityGate>
</div>
);
}
/**
* File mode is a different rendering of the same facts, not a smaller set of
* them: the Suggested provenance and both skipped-line counters belong here
* too. A list whose lines nxdns could not take is a failure the reader must
* see under either authority.
*/
function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
return (
<section {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>
Blocklist sources
<code {...stylex.props(shared.mono, config.panelKey)}>blocklist_sources</code>
</h2>
{blocklists.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no blocklist sources.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.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>
</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, shared.mono)}>{b.url}</td>
<td {...stylex.props(shared.td)}>{String(b.enabled)}</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>
</tr>
))}
</tbody>
</table>
<p {...stylex.props(config.note)}>{SKIPPED_NOTE}</p>
</div>
)}
</section>
);
}
function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
@@ -92,13 +188,6 @@ export default function BlocklistsPage() {
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) {
@@ -126,30 +215,12 @@ export default function BlocklistsPage() {
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} />
<div>
{blocklists.length === 0 ? (
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
<p {...stylex.props(config.empty)}>No blocklist sources yet. Add one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
@@ -179,14 +250,11 @@ export default function BlocklistsPage() {
</span>
</td>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
<Switch
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
isSelected={b.enabled}
isDisabled={toggle.isPending}
onChange={() => toggleEnabled(b)}
{...stylex.props(shared.focusRing)}
/>
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
@@ -204,22 +272,19 @@ export default function BlocklistsPage() {
<button
type="button"
onClick={() => setEditing(b)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
{...stylex.props(shared.linkButton, 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)}
disabled={remove.isPending}
{...stylex.props(
shared.dangerLinkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -229,13 +294,7 @@ export default function BlocklistsPage() {
))}
</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>
<p {...stylex.props(config.note)}>{SKIPPED_NOTE}</p>
</div>
)}
<InlineError error={tableError} />
@@ -244,14 +303,11 @@ export default function BlocklistsPage() {
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"
@@ -264,6 +320,6 @@ export default function BlocklistsPage() {
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
</div>
);
}
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import type { UseQueryResult } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { styles } from "./styles";
/**
* One panel's data, with the loading and error surfaces the fire-and-forget
* route loaders leave to the page. A configuration page holds several
* independent collections; each states its own condition instead of the whole
* page waiting on the slowest request.
*/
export default function QueryPanel<T>({
query,
children,
}: {
query: UseQueryResult<T, Error>;
children: (data: T) => ReactNode;
}) {
if (query.isPending) {
return (
<p role="status" {...stylex.props(styles.pending, shared.pulse)}>
Loading
</p>
);
}
if (query.isError) return <InlineError error={query.error} onRetry={() => void query.refetch()} />;
return <>{children(query.data)}</>;
}
@@ -1,5 +1,5 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
localRecordCreateMutation,
@@ -12,9 +12,9 @@ import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
import QueryPanel from "./QueryPanel";
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 }));
@@ -93,14 +93,12 @@ const styles = stylex.create({
function RecordForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: LocalRecord;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: LocalRecordInput) => void;
onCancel: () => void;
@@ -174,12 +172,7 @@ function RecordForm({
/>
</div>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
<button type="submit" disabled={busy} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
@@ -192,7 +185,7 @@ function RecordForm({
}
export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data;
const query = useQuery(localRecordsQuery());
const {
create,
update,
@@ -211,7 +204,6 @@ export default function RecordsTab() {
remove: localRecordDeleteMutation,
confirmDelete: (record) => `Delete record "${record.name}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
@@ -220,8 +212,6 @@ export default function RecordsTab() {
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add record
@@ -229,98 +219,92 @@ export default function RecordsTab() {
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<RecordForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
<RecordForm busy={create.isPending} 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>
<QueryPanel query={query}>
{(records) => (
<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>
)}
</tr>
))}
</tbody>
</table>
</div>
{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}
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 })}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(record)}
disabled={remove.isPending}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</QueryPanel>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
@@ -0,0 +1,47 @@
import { useMutation } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { reloadCerts } from "@/lib/api";
import type { CertReloadOutcome, CertsReload } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { styles } from "./styles";
function describe(name: string, outcome: CertReloadOutcome): string {
if (!outcome.enabled) return `${name}: not enabled`;
if (outcome.reloaded) return `${name}: reloaded`;
return `${name}: failed — ${outcome.error ?? "no reason given"}`;
}
/**
* A runtime action, not a configuration write: it re-reads the certificate
* files the running listeners already point at, so it stays enabled under
* every authority including a file-managed process, where renewing a
* certificate is exactly the job that must not need a restart.
*
* The endpoint answers 200 even when a reload fails, per endpoint, because a
* failed reload leaves the previous certificate serving. So the outcome is
* rendered as a result, and only a transport or auth failure is an error.
*/
export default function ReloadCertsAction() {
const mutation = useMutation<CertsReload>({ mutationFn: reloadCerts });
const result = mutation.data;
return (
<div {...stylex.props(styles.actionRow)}>
<button
type="button"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
{...stylex.props(shared.button, shared.focusRing)}
>
{mutation.isPending ? "Reloading certificates…" : "Reload certificates"}
</button>
{result !== undefined && (
<p role="status" {...stylex.props(styles.success)}>
{describe("DoH", result.doh)}. {describe("DoT", result.dot)}.
</p>
)}
<InlineError error={mutation.error} />
</div>
);
}
@@ -0,0 +1,253 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { DATABASE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
/**
* Resolution in database mode: the upstream pool, the local records and the
* forward zones, each rehomed from its own page onto a tab of one.
*/
let calls: Call[];
afterEach(() => {
vi.unstubAllGlobals();
});
function writes(): Call[] {
return calls;
}
async function openResolution(tab?: string, options: Parameters<typeof stubApi>[1] = {}) {
calls = stubApi(DATABASE, options);
const suffix = tab === undefined ? "" : `?tab=${tab}`;
return renderPage(`/configuration/resolution${suffix}`, "Resolution");
}
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog(index = 0) {
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[index]!);
return await screen.findByRole("alertdialog");
}
test("the upstream pool is the default tab and lists every field", async () => {
await openResolution();
expect(await screen.findByText("udp://1.1.1.1:53")).toBeTruthy();
expect(screen.getByText("tls://9.9.9.9:853")).toBeTruthy();
expect(screen.getByText("dns.quad9.net")).toBeTruthy();
expect((screen.getByLabelText("udp://1.1.1.1:53 enabled") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("tls://9.9.9.9:853 enabled") as HTMLInputElement).checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
expect(screen.getByText(/applies to the next query/)).toBeTruthy();
});
test("adding an upstream posts every field", async () => {
await openResolution();
await screen.findByRole("heading", { name: "Add upstream" });
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "150" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toEqual({
url: "/api/upstreams",
method: "POST",
body: { url: "udp://8.8.8.8:53", priority: 150, enabled: true, tls_name: "" },
});
});
test("an upstream write applies live, so the tab says nothing about a restart", async () => {
// The server rebuilds the pool on the write and echoes `restart_required:
// false`, so `restart_pending` stays down and silence is the whole report.
await openResolution(undefined, { responses: { "GET /api/config/status": () => DATABASE } });
await screen.findByRole("heading", { name: "Add upstream" });
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(screen.queryByText(/Restart nxdns to apply them/)).toBeNull();
expect(contentArea().textContent).not.toMatch(/restart/i);
});
test("toggling enabled resends the whole row", async () => {
await openResolution();
await screen.findByLabelText("tls://9.9.9.9:853 enabled");
fireEvent.click(screen.getByLabelText("tls://9.9.9.9:853 enabled"));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toEqual({
url: "/api/upstreams/2",
method: "PUT",
body: { url: "tls://9.9.9.9:853", priority: 200, enabled: true, tls_name: "dns.quad9.net" },
});
});
test("upstream delete asks for confirmation and skips the request when cancelled", async () => {
await openResolution();
await screen.findByText("udp://1.1.1.1:53");
const dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes()).toHaveLength(0);
});
test("confirming the upstream delete dialog issues the DELETE", async () => {
await openResolution();
await screen.findByText("udp://1.1.1.1:53");
const dialog = await openDeleteDialog();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/upstreams/1" });
});
test("a 409 on create renders the conflict text inline", async () => {
await openResolution(undefined, {
onWrite: () =>
new Response(JSON.stringify({ error: "an upstream with that url already exists" }), {
status: 409,
headers: { "content-type": "application/json" },
}),
});
await screen.findByRole("heading", { name: "Add upstream" });
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://1.1.1.1:53" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("an upstream with that url already exists");
});
test("a 409 on toggle renders the last-enabled conflict", async () => {
await openResolution(undefined, {
onWrite: () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be disabled" }), {
status: 409,
headers: { "content-type": "application/json" },
}),
});
await screen.findByLabelText("udp://1.1.1.1:53 enabled");
fireEvent.click(screen.getByLabelText("udp://1.1.1.1:53 enabled"));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be disabled");
});
test("a 409 on delete renders the last-enabled conflict", async () => {
await openResolution(undefined, {
onWrite: () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
status: 409,
headers: { "content-type": "application/json" },
}),
});
await screen.findByText("udp://1.1.1.1:53");
const dialog = await openDeleteDialog();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
});
test("editing a row seeds the form and PUTs the replaced row", async () => {
await openResolution();
await screen.findByText("tls://9.9.9.9:853");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[1]!);
await screen.findByRole("heading", { name: "Edit tls://9.9.9.9:853" });
expect((screen.getByLabelText("URL") as HTMLInputElement).value).toBe("tls://9.9.9.9:853");
expect((screen.getByLabelText("Priority") as HTMLInputElement).value).toBe("200");
expect((screen.getByLabelText("TLS name") as HTMLInputElement).value).toBe("dns.quad9.net");
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "10" } });
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toEqual({
url: "/api/upstreams/2",
method: "PUT",
body: { url: "tls://9.9.9.9:853", priority: 10, enabled: false, tls_name: "dns.quad9.net" },
});
await screen.findByRole("heading", { name: "Add upstream" });
});
test("the arrow keys move between tabs, and the panel follows", async () => {
await openResolution();
await screen.findByText("udp://1.1.1.1:53");
const tablist = screen.getByRole("tablist", { name: "Resolution" });
expect(screen.getByRole("tab", { name: "Upstreams" }).getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
await waitFor(() =>
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true"),
);
await screen.findByText("nas.lan.home");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
await waitFor(() =>
expect(screen.getByRole("tab", { name: "Upstreams" }).getAttribute("aria-selected")).toBe("true"),
);
await screen.findByText("udp://1.1.1.1:53");
});
test("creating a local record posts exactly the LocalRecordInput", async () => {
await openResolution("records");
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: 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 waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toMatchObject({
url: "/api/local-records",
method: "POST",
body: { name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" },
});
});
test("the record delete dialog names the record and only deletes on confirm", async () => {
await openResolution("records");
await screen.findByText("nas.lan.home");
let dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes()).toHaveLength(0);
dialog = await openDeleteDialog();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/local-records/1" });
});
test("the forward zone delete dialog names the zone and only deletes on confirm", async () => {
await openResolution("zones");
await screen.findByText("lan.home");
let dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(writes()).toHaveLength(0);
dialog = await openDeleteDialog();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(writes()).toHaveLength(1));
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/forward-zones/1" });
});
@@ -0,0 +1,77 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { forwardZonesQuery, localRecordsQuery } from "@/lib/queries";
import Tabs from "@/ui/Tabs";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import { RecordsReadOnly, ZonesReadOnly } from "./LocalReadOnly";
import QueryPanel from "./QueryPanel";
import RecordsTab from "./RecordsTab";
import UpstreamsTab from "./UpstreamsTab";
import ZonesTab from "./ZonesTab";
import { styles } from "./styles";
import type { ResolutionTab } from "./search";
function RecordsPanel() {
const records = useQuery(localRecordsQuery());
return (
<AuthorityGate>
{(status) =>
status.authority === "managed_file" ? (
<>
<FileModeNote path={status.path} />
<QueryPanel query={records}>{(rows) => <RecordsReadOnly records={rows} />}</QueryPanel>
</>
) : (
<RecordsTab />
)
}
</AuthorityGate>
);
}
function ZonesPanel() {
const zones = useQuery(forwardZonesQuery());
return (
<AuthorityGate>
{(status) =>
status.authority === "managed_file" ? (
<>
<FileModeNote path={status.path} />
<QueryPanel query={zones}>{(rows) => <ZonesReadOnly zones={rows} />}</QueryPanel>
</>
) : (
<ZonesTab />
)
}
</AuthorityGate>
);
}
/**
* Resolution: where a permitted name gets its answer. Three tabs in the order
* a query meets them the pool that answers most of them, the records nxdns
* answers itself, and the zones it hands to another resolver.
*/
export default function ResolutionPage() {
const tab = useSearch({ from: "/shell/configuration/resolution" }).tab ?? "upstreams";
const navigate = useNavigate({ from: "/configuration/resolution" });
return (
<section>
<h1 {...stylex.props(styles.heading)}>Resolution</h1>
<p {...stylex.props(styles.intro)}>Where nxdns answers or forwards the names it permits.</p>
<Tabs
label="Resolution"
selectedKey={tab}
onSelectionChange={(key) => void navigate({ search: { tab: key as ResolutionTab } })}
tabs={[
{ id: "upstreams", label: "Upstreams", content: <UpstreamsTab /> },
{ id: "records", label: "Records", content: <RecordsPanel /> },
{ id: "zones", label: "Forward zones", content: <ZonesPanel /> },
]}
/>
</section>
);
}
@@ -0,0 +1,67 @@
import * as stylex from "@stylexjs/stylex";
import type { Settings } from "@/lib/types";
import DefinitionList, { type Definition } from "@/ui/DefinitionList";
import { colors } from "@/ui/tokens.stylex";
import { SECTIONS, sectionValues, type AnyFieldDef } from "./settingsSections";
import { styles as config } from "./styles";
const styles = stylex.create({
absent: {
color: colors.textMuted,
},
});
/** The field key as a sentence. The exact key travels beside it, so this is free to read well. */
export function humanize(key: string): string {
const spaced = key.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/**
* Values are shown as the file spells them: `true`, not "Yes". The reader's
* next step is editing that file, and a value they cannot type back is a
* translation they have to undo.
*/
function renderValue(value: unknown) {
if (typeof value === "boolean") return String(value);
if (typeof value === "number") return String(value);
if (value === "") return <span {...stylex.props(styles.absent)}>empty</span>;
return String(value);
}
/**
* The settings under file authority: definition lists, one per section, each
* scalar carrying its exact ZON key. No inputs, no Save the file is the
* form.
*/
export default function SettingsDefinitions({ settings }: { settings: Settings }) {
return (
<>
{SECTIONS.map(({ section, title, fields }) => {
const values = sectionValues(settings, section);
const items: Definition[] = (fields as readonly AnyFieldDef[]).map((def) => ({
label: humanize(def.key),
zonKey: `${section}.${def.key}`,
value: renderValue(values[def.key]),
}));
if (section === "web") {
// Derived from whether a password hash is stored, so it has no key of
// its own (D13). Naming one would send the reader to a line that is
// not in the file.
items.push({
label: "Authentication",
value: settings.web.auth_enabled ? "required" : "not configured",
});
}
return (
<section key={section} {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>{title}</h2>
<div {...stylex.props(config.note)}>
<DefinitionList items={items} />
</div>
</section>
);
})}
</>
);
}
@@ -0,0 +1,342 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { settingsPutMutation } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsEnvelope } from "@/lib/types";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { SECTIONS, sectionValues, type AnyFieldDef } from "./settingsSections";
import { styles as config } from "./styles";
const DARK = "@media (prefers-color-scheme: dark)";
/** The form is rendered once per page, so the message can hold a fixed id. */
const MISMATCH_ID = "web.password_mismatch";
const styles = stylex.create({
form: {
marginTop: "1rem",
maxWidth: "48rem",
},
/** A `fieldset` has a browser default border and padding; the layout wants neither. */
sections: {
display: "flex",
flexDirection: "column",
gap: "1.5rem",
borderStyle: "none",
margin: 0,
padding: 0,
},
section: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
legend: {
paddingInline: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
/** One column on a phone, two from `sm`. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(37% 0.013 285.805)",
[DARK]: "oklch(87.1% 0.006 286.286)",
},
},
checkboxRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
fieldInput: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
derived: {
color: colors.textMuted,
},
/** Both notices span the whole grid so the wrapped sentence stays readable. */
spanRow: {
gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" },
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
passwordNotice: {
color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" },
},
mismatchNotice: {
color: colors.danger,
},
submitRow: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
save: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
backgroundColor: {
default: colors.primary,
":disabled": "oklch(87.1% 0.006 286.286)",
[DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" },
},
color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" },
},
});
function FieldLabel({ id, text, restart }: { id: string; text: string; restart: boolean }) {
return (
<label htmlFor={id} {...stylex.props(styles.label)}>
{text}
{restart && <span {...stylex.props(config.restartTag)}>needs restart</span>}
</label>
);
}
function FieldRow({
section,
def,
value,
restart,
onChange,
}: {
section: string;
def: AnyFieldDef;
value: unknown;
restart: boolean;
onChange: (value: unknown) => void;
}) {
const id = `${section}.${def.key}`;
if (def.kind === "boolean") {
return (
<div {...stylex.props(styles.checkboxRow)}>
<input
id={id}
type="checkbox"
checked={value as boolean}
onChange={(e) => onChange(e.target.checked)}
{...stylex.props(shared.focusRing)}
/>
<FieldLabel id={id} text={def.key} restart={restart} />
</div>
);
}
if (Array.isArray(def.kind)) {
// `Select` renders its own `<label>` from a string, so the marker cannot be
// placed inside that label the way `FieldLabel` does it; it goes through the
// description slot instead, which puts it under the control and on the
// trigger's `aria-describedby`. An enum key that owes a restart owes it just
// as much as a number one, so it must be marked either way.
return (
<div {...stylex.props(styles.field)}>
<Select
variant="inline"
label={def.key}
value={value as string}
onChange={onChange}
options={def.kind.map((option) => ({ value: option, label: option }))}
description={restart ? "needs restart" : undefined}
/>
</div>
);
}
if (def.kind === "number") {
const numeric = value as number;
// An empty or unparseable number reads back as NaN. The form already refuses
// to submit on it; this is what says so to a screen reader.
return (
<div {...stylex.props(styles.field)}>
<FieldLabel id={id} text={def.key} restart={restart} />
<input
id={id}
type="number"
aria-invalid={Number.isNaN(numeric) || undefined}
value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
);
}
return (
<div {...stylex.props(styles.field)}>
<FieldLabel id={id} text={def.key} restart={restart} />
<input
id={id}
type="text"
value={value as string}
onChange={(e) => onChange(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
);
}
/**
* The editable settings, in database mode only. Nothing here is ever disabled
* for authority: under file authority the page renders definitions instead, so
* this form is never drawn as a shell the reader cannot use.
*
* Which keys owe a restart is the server's answer, carried on the envelope's
* `restart_required` list; whether one is owed *now* is `restart_pending` on
* `/api/config/status`, which the shell notice reads.
*/
export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope }) {
const queryClient = useQueryClient();
const mutation = useMutation(settingsPutMutation(queryClient));
// Frozen at mount and re-frozen on save: diffing against live query data would
// turn a background refetch's out-of-band changes into phantom user edits.
const [baseline, setBaseline] = useState<Settings>(() => structuredClone(envelope.settings));
const [edited, setEdited] = useState<Settings>(() => structuredClone(envelope.settings));
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const restartKeys = new Set(envelope.restart_required);
const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm;
const hasInvalidNumber = SECTIONS.some(({ section, fields }) => {
const values = sectionValues(edited, section);
return (fields as readonly AnyFieldDef[]).some(
(field) => field.kind === "number" && Number.isNaN(values[field.key]),
);
});
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
function setField(section: keyof Settings, key: string, value: unknown): void {
setEdited((prev) => ({
...prev,
[section]: { ...sectionValues(prev, section), [key]: value },
}));
}
function handleSubmit(event: FormEvent): void {
event.preventDefault();
if (patch === null || passwordsMismatch || hasInvalidNumber) return;
mutation.mutate(patch, {
onSuccess: (saved) => {
setBaseline(structuredClone(saved.settings));
setEdited(structuredClone(saved.settings));
setPassword("");
setConfirm("");
},
});
}
return (
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<fieldset disabled={mutation.isPending} {...stylex.props(styles.sections)}>
{SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} {...stylex.props(styles.section)}>
<legend {...stylex.props(styles.legend)}>{title}</legend>
<div {...stylex.props(styles.fieldGrid)}>
{(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow
key={def.key}
section={section}
def={def}
value={sectionValues(edited, section)[def.key]}
restart={restartKeys.has(`${section}.${def.key}`)}
onChange={(value) => setField(section, def.key, value)}
/>
))}
{section === "web" && (
<>
<p {...stylex.props(styles.label)}>
Authentication:{" "}
{envelope.settings.web.auth_enabled ? "required" : "not configured"}{" "}
<span {...stylex.props(styles.derived)}>
(derived from whether a password is stored)
</span>
</p>
<div {...stylex.props(styles.field)}>
<label htmlFor="web.password" {...stylex.props(styles.label)}>
password
</label>
<input
id="web.password"
type="password"
autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={password}
onChange={(e) => setPassword(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.field)}>
<label htmlFor="web.password_confirm" {...stylex.props(styles.label)}>
confirm password
</label>
<input
id="web.password_confirm"
type="password"
autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
/>
</div>
{password !== "" && (
<p {...stylex.props(styles.spanRow, styles.passwordNotice)}>
Changing the password signs out every session; you will be asked to log in
again.
</p>
)}
{passwordsMismatch && (
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match.
</p>
)}
</>
)}
</div>
</fieldset>
))}
<div {...stylex.props(styles.submitRow)}>
<button type="submit" disabled={saveDisabled} {...stylex.props(styles.save, shared.focusRing)}>
{mutation.isPending ? "Saving…" : "Save"}
</button>
{mutation.isError && <InlineError error={mutation.error} />}
</div>
</fieldset>
</form>
);
}
@@ -0,0 +1,403 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { queryKeys } from "@/lib/queries";
import type { Settings, SettingsPatch } from "@/lib/types";
import { DATABASE, MANAGED_FILE, RESTART_REQUIRED_KEYS, baseSettings, renderPage, stubApi } from "./testFixtures";
/**
* System in database mode: the settings form, its diff contract, and the
* certificate reload that is a runtime action under both authorities.
*/
// What the server actually reports: the listener binds and `web.enabled`.
// Every other key applies live, so it carries no mark at all.
const RESTART_KEYS = RESTART_REQUIRED_KEYS;
let stored: Settings;
let putBodies: SettingsPatch[];
let putResponse: (() => Response | Promise<Response>) | null;
let restartPending: boolean;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
/** The server's echo: the patch merged into the stored settings, password excepted. */
function applyPatch(patch: SettingsPatch): void {
const target = stored as unknown as Record<string, Record<string, unknown>>;
for (const [section, fields] of Object.entries(patch)) {
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
if (section === "web" && key === "password") continue;
target[section]![key] = value;
}
}
}
/** Mirrors apply.zig's table: only a listed key leaves the server owing a restart. */
function needsRestart(patch: SettingsPatch, keys: readonly string[]): boolean {
return Object.entries(patch).some(([section, fields]) =>
Object.keys(fields as Record<string, unknown>).some((key) => keys.includes(`${section}.${key}`)),
);
}
beforeEach(() => {
stored = baseSettings();
putBodies = [];
putResponse = null;
restartPending = false;
});
afterEach(() => {
vi.unstubAllGlobals();
});
async function openSystem(restartKeys: readonly string[] = RESTART_KEYS) {
stubApi(DATABASE, {
responses: {
"GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }),
"GET /api/settings": () => ({ settings: stored, restart_required: restartKeys }),
},
onWrite: (call) => {
if (call.url !== "/api/settings") return null;
const patch = call.body as SettingsPatch;
putBodies.push(patch);
if (putResponse !== null) return putResponse();
applyPatch(patch);
if (needsRestart(patch, restartKeys)) restartPending = true;
return json({ settings: stored, restart_required: restartKeys });
},
});
const router = await renderPage("/configuration/system", "System");
await screen.findByRole("button", { name: "Save" });
return router;
}
function saveButton(): HTMLButtonElement {
return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
}
function restartNotice(): HTMLElement | null {
return screen.queryByText(/Saved changes are not running yet/);
}
test("no changes means Save is disabled, and authentication reads as derived", async () => {
await openSystem();
expect(saveButton().disabled).toBe(true);
const auth = screen.getByText(/^Authentication: required/);
expect(auth.textContent).toContain("derived from whether a password is stored");
});
test("a changed field enables Save and the PUT body is exactly the diff", async () => {
await openSystem();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
await waitFor(() => expect(saveButton().disabled).toBe(true));
});
test("a restart-required key is marked as one, from the envelope's list", async () => {
await openSystem();
// The DNS binds and the port are the section's whole share of the list;
// `rate_limit` and `rate_window_seconds` apply live and carry no mark.
const dns = screen.getByRole("group", { name: "DNS" });
expect(within(dns).getAllByText("needs restart")).toHaveLength(3);
const cache = screen.getByRole("group", { name: "Cache" });
expect(within(cache).queryByText("needs restart")).toBeNull();
});
test("every key the server applies live is drawn without restart messaging", async () => {
await openSystem();
// Silence is the report for a live key: no mark on the field, and editing
// one owes nothing afterwards either.
for (const title of ["Upstream", "Blocking", "Cache", "EDNS", "Logging", "Disk", "Blocklist Update"]) {
const section = screen.getByRole("group", { name: title });
expect(within(section).queryByText("needs restart")).toBeNull();
}
const logging = screen.getByRole("group", { name: "Logging" });
fireEvent.change(within(logging).getByLabelText("retention_days"), { target: { value: "14" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ logging: { retention_days: 14 } });
await waitFor(() => expect(saveButton().disabled).toBe(true));
expect(restartNotice()).toBeNull();
});
test("a port edit still owes a restart, and the shell says so", async () => {
await openSystem();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
await screen.findByText(/Saved changes are not running yet/);
});
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
// No shipped restart-required key is enum-backed, but the list is the
// server's to change, so the `Select` rendering is pinned against one.
await openSystem(["logging.level"]);
const logging = screen.getByRole("group", { name: "Logging" });
// `logging.level` is a Select and `logging.output` is not on the list, so
// exactly one mark belongs to this section.
expect(within(logging).getAllByText("needs restart")).toHaveLength(1);
const marker = within(logging).getByText("needs restart");
const marked = marker.parentElement!;
const trigger = within(marked).getByRole("button", { name: /level$/ });
// Visible next to the control is not enough: the marker sits outside the
// label, so only `aria-describedby` carries it to a screen reader.
expect(marker.id).not.toBe("");
expect(trigger.getAttribute("aria-describedby")?.split(" ")).toContain(marker.id);
});
test("enum and boolean fields diff as their own types", async () => {
await openSystem();
const logging = screen.getByRole("group", { name: "Logging" });
// A RAC Select names its trigger with the current value and then the label,
// and carries the options only while the listbox is open.
fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
fireEvent.click(await screen.findByRole("option", { name: "debug" }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
fireEvent.click(within(logging).getByLabelText("hide_domains"));
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
});
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
test("clearing a number field disables Save instead of sending NaN", async () => {
await openSystem();
const cache = screen.getByRole("group", { name: "Cache" });
const size = within(cache).getByLabelText("size");
fireEvent.change(size, { target: { value: "" } });
expect(saveButton().disabled).toBe(true);
// The refusal is on the field itself, not only on the Save button.
expect(size.getAttribute("aria-invalid")).toBe("true");
fireEvent.change(size, { target: { value: "512" } });
expect(size.getAttribute("aria-invalid")).toBeNull();
});
test("the mismatch message is attached to both password inputs", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
const passwordInput = within(web).getByLabelText("password");
const confirmInput = within(web).getByLabelText("confirm password");
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(describedText(input)).toBe("Passwords do not match.");
}
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBeNull();
expect(input.getAttribute("aria-describedby")).toBeNull();
}
});
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
const passwordInput = within(web).getByLabelText("password") as HTMLInputElement;
const confirmInput = within(web).getByLabelText("confirm password") as HTMLInputElement;
expect(passwordInput.value).toBe("");
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
expect(screen.getByText(/signs out every session/)).toBeTruthy();
expect(screen.getByText("Passwords do not match.")).toBeTruthy();
expect(saveButton().disabled).toBe(true);
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
expect(screen.queryByText("Passwords do not match.")).toBeNull();
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ web: { password: "hunter2" } });
await waitFor(() => expect(passwordInput.value).toBe(""));
expect(confirmInput.value).toBe("");
// The password applies live, so the server never raises the flag.
expect(restartNotice()).toBeNull();
});
test("a patch of live keys alone leaves the server owing nothing", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
fireEvent.change(within(web).getByLabelText("session_ttl_hours"), { target: { value: "48" } });
fireEvent.change(within(web).getByLabelText("password"), { target: { value: "hunter2" } });
fireEvent.change(within(web).getByLabelText("confirm password"), { target: { value: "hunter2" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
await waitFor(() => expect(saveButton().disabled).toBe(true));
expect(restartNotice()).toBeNull();
});
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
await openSystem();
let resolvePut!: (response: Response) => void;
putResponse = () => new Promise<Response>((resolve) => (resolvePut = resolve));
const dns = screen.getByRole("group", { name: "DNS" });
const port = within(dns).getByLabelText(/^port/) as HTMLInputElement;
fireEvent.change(port, { target: { value: "5353" } });
fireEvent.click(saveButton());
await screen.findByRole("button", { name: "Saving…" });
expect(port.matches(":disabled")).toBe(true);
expect(screen.getByRole("group", { name: "Web" }).querySelector("#web\\.password")?.matches(":disabled")).toBe(
true,
);
applyPatch(putBodies[putBodies.length - 1]!);
resolvePut(json({ settings: stored, restart_required: RESTART_KEYS }));
await waitFor(() => expect(port.matches(":disabled")).toBe(false));
expect(saveButton().textContent).toBe("Save");
});
test("a 429 shows the rate-limit countdown from Retry-After", async () => {
await openSystem();
putResponse = () =>
new Response(JSON.stringify({ error: "too many requests" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "30" },
});
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
fireEvent.click(saveButton());
expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 30s.");
expect(restartNotice()).toBeNull();
});
test("a 400 validation error surfaces inline and owes no restart", async () => {
await openSystem();
putResponse = () => json({ error: "dns.port out of range" }, 400);
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "70000" } });
fireEvent.click(saveButton());
expect((await screen.findByRole("alert")).textContent).toBe("dns.port out of range");
expect(restartNotice()).toBeNull();
expect(saveButton().disabled).toBe(false);
});
test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
const router = await openSystem();
const queryClient = router.options.context.queryClient;
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
// Someone else changes cache.size; a background refetch brings it in. The
// derived authentication line is read straight from the query data, so it
// witnesses that the refetch reached the component.
stored.cache.size = 99999;
stored.web.auth_enabled = false;
await act(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
});
await waitFor(() => expect(screen.getByText(/^Authentication: not configured/)).toBeTruthy());
const cache = screen.getByRole("group", { name: "Cache" });
expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000");
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
});
test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => {
await openSystem();
const dns = screen.getByRole("group", { name: "DNS" });
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
await waitFor(() => expect(saveButton().disabled).toBe(true));
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5454" } });
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(2));
expect(putBodies[1]).toEqual({ dns: { port: 5454 } });
});
test("Reload certificates reports each endpoint's outcome in database mode (D8)", async () => {
stubApi(DATABASE, {
responses: {
"POST /api/certs/reload": {
doh: { enabled: true, reloaded: true, error: null },
dot: { enabled: false, reloaded: false, error: null },
},
},
});
await renderPage("/configuration/system", "System");
fireEvent.click(screen.getByRole("button", { name: "Reload certificates" }));
const result = await screen.findByText(/DoH: reloaded/);
expect(result.textContent).toContain("DoT: not enabled");
});
test("Reload certificates works under file authority too, and states a failure (D8)", async () => {
stubApi(MANAGED_FILE, {
responses: {
"POST /api/certs/reload": {
doh: { enabled: true, reloaded: false, error: "cert.pem: no such file" },
dot: { enabled: true, reloaded: true, error: null },
},
},
});
await renderPage("/configuration/system", "System");
const button = screen.getByRole("button", { name: "Reload certificates" }) as HTMLButtonElement;
expect(button.disabled).toBe(false);
fireEvent.click(button);
const result = await screen.findByText(/DoH: failed/);
expect(result.textContent).toContain("cert.pem: no such file");
expect(result.textContent).toContain("DoT: reloaded");
});
test("a failed certificate reload request is an error, not an outcome", async () => {
stubApi(DATABASE, {
onWrite: (call) => (call.url === "/api/certs/reload" ? json({ error: "reload is busy" }, 409) : null),
});
await renderPage("/configuration/system", "System");
fireEvent.click(screen.getByRole("button", { name: "Reload certificates" }));
expect((await screen.findByRole("alert")).textContent).toBe("reload is busy");
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { settingsQuery } from "@/lib/queries";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel";
import ReloadCertsAction from "./ReloadCertsAction";
import SettingsDefinitions from "./SettingsDefinitions";
import SettingsForm from "./SettingsForm";
import { styles } from "./styles";
/**
* System: what this process is running with. No tabs the settings registry
* is already sectioned, and splitting it further would hide the section the
* reader came for behind a guess about which tab holds it.
*
* "Reload certificates" sits above the gate: it is a runtime action on the
* running listeners, so it works whatever owns the configuration and whether
* or not `/api/config/status` has answered.
*/
export default function SystemPage() {
const settings = useQuery(settingsQuery());
return (
<section>
<h1 {...stylex.props(styles.heading)}>System</h1>
<p {...stylex.props(styles.intro)}>
The service, storage, logging, TLS and web settings this nxdns process is running with.
</p>
<ReloadCertsAction />
<AuthorityGate>
{(status) =>
status.authority === "managed_file" ? (
<>
<FileModeNote path={status.path} />
<QueryPanel query={settings}>
{(envelope) => <SettingsDefinitions settings={envelope.settings} />}
</QueryPanel>
</>
) : (
<QueryPanel query={settings}>{(envelope) => <SettingsForm envelope={envelope} />}</QueryPanel>
)
}
</AuthorityGate>
</section>
);
}
@@ -4,15 +4,12 @@ import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100";
interface UpstreamFormProps {
initial?: Upstream;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: UpstreamInput) => Promise<void>;
onCancel?: () => void;
@@ -61,7 +58,7 @@ const styles = stylex.create({
},
});
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel }: UpstreamFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -145,12 +142,7 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
Enabled
</label>
<div {...stylex.props(styles.actions)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
<button type="submit" disabled={busy} {...stylex.props(shared.primaryButton, shared.focusRing)}>
{initial === undefined ? "Add upstream" : "Save changes"}
</button>
{onCancel !== undefined && (
@@ -1,40 +1,22 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm";
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";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel";
import UpstreamForm from "./UpstreamForm";
import { styles as config } from "./styles";
const INTRO = "The pool is rebuilt as you save, so an edit here applies to the next query.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
maxWidth: "42rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
url: {
display: "block",
maxWidth: "18rem",
@@ -50,11 +32,79 @@ const styles = stylex.create({
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
absent: {
color: colors.textMuted,
},
});
export default function UpstreamsPage() {
export default function UpstreamsTab() {
const upstreams = useQuery(upstreamsQuery());
return (
<AuthorityGate>
{(status) => (
<div>
<p {...stylex.props(config.intro)}>{INTRO}</p>
{status.authority === "managed_file" && <FileModeNote path={status.path} />}
<QueryPanel query={upstreams}>
{(rows) =>
status.authority === "managed_file" ? (
<UpstreamsReadOnly upstreams={rows} />
) : (
<UpstreamsEditor upstreams={rows} />
)
}
</QueryPanel>
</div>
)}
</AuthorityGate>
);
}
function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
return (
<section {...stylex.props(config.panel)}>
<h2 {...stylex.props(config.panelHeading)}>
Upstream pool
<code {...stylex.props(shared.mono, config.panelKey)}>upstreams</code>
</h2>
{upstreams.length === 0 ? (
<p {...stylex.props(config.empty)}>The file declares no upstreams.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Priority</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>TLS name</th>
</tr>
</thead>
<tbody>
{upstreams.map((u) => (
<tr key={u.id}>
<td {...stylex.props(shared.td, shared.mono)}>{u.url}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>{String(u.enabled)}</td>
<td {...stylex.props(shared.td)}>
{u.tls_name === "" ? (
<span {...stylex.props(styles.absent)}>empty</span>
) : (
u.tls_name
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
const queryClient = useQueryClient();
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
const [editing, setEditing] = useState<Upstream | null>(null);
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
@@ -62,7 +112,6 @@ export default function UpstreamsPage() {
const save = useMutation(upstreamUpdateMutation(queryClient));
const toggle = useMutation(upstreamUpdateMutation(queryClient));
const remove = useMutation(upstreamDeleteMutation(queryClient));
const readOnly = useReadOnlyConfig();
async function submitForm(input: UpstreamInput) {
if (editing === null) {
@@ -71,22 +120,18 @@ export default function UpstreamsPage() {
await save.mutateAsync({ id: editing.id, input });
setEditing(null);
}
raiseRestartBanner();
}
function toggleEnabled(u: Upstream) {
toggle.mutate(
{
id: u.id,
input: { url: u.url, priority: u.priority, enabled: !u.enabled, tls_name: u.tls_name },
},
{ onSuccess: () => raiseRestartBanner() },
);
toggle.mutate({
id: u.id,
input: { url: u.url, priority: u.priority, enabled: !u.enabled, tls_name: u.tls_name },
});
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
@@ -94,18 +139,12 @@ export default function UpstreamsPage() {
const tableError = remove.error ?? toggle.error;
return (
<section>
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
<p {...stylex.props(styles.intro)}>
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
health table on the Dashboard reflects the running pool, not this list.
</p>
<div>
{upstreams.length === 0 ? (
<p {...stylex.props(styles.empty)}>No upstreams yet. Add one below.</p>
<p {...stylex.props(config.empty)}>No upstreams yet. Add one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>URL</th>
@@ -127,14 +166,11 @@ export default function UpstreamsPage() {
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
<Switch
aria-label={`${u.url} enabled`}
checked={u.enabled}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
isSelected={u.enabled}
isDisabled={toggle.isPending}
onChange={() => toggleEnabled(u)}
{...stylex.props(shared.focusRing)}
/>
</td>
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
@@ -143,22 +179,19 @@ export default function UpstreamsPage() {
<button
type="button"
onClick={() => setEditing(u)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
{...stylex.props(shared.linkButton, shared.focusRing)}
>
Edit
</button>
<button
type="button"
onClick={() => setPendingDelete(u)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
disabled={remove.isPending}
{...stylex.props(
shared.dangerLinkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -176,7 +209,6 @@ export default function UpstreamsPage() {
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)}
@@ -194,6 +226,6 @@ export default function UpstreamsPage() {
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
</div>
);
}
@@ -1,5 +1,5 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
forwardZoneCreateMutation,
@@ -11,9 +11,9 @@ import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm";
import QueryPanel from "./QueryPanel";
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: {
@@ -89,14 +89,12 @@ const styles = stylex.create({
function ZoneForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: ForwardZone;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: ForwardZoneInput) => void;
onCancel: () => void;
@@ -142,12 +140,7 @@ function ZoneForm({
/>
</div>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
<button type="submit" disabled={busy} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
@@ -160,7 +153,7 @@ function ZoneForm({
}
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const query = useQuery(forwardZonesQuery());
const {
create,
update,
@@ -179,7 +172,6 @@ export default function ZonesTab() {
remove: forwardZoneDeleteMutation,
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
@@ -190,8 +182,6 @@ export default function ZonesTab() {
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add zone
@@ -199,90 +189,84 @@ export default function ZonesTab() {
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<ZoneForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
<ZoneForm busy={create.isPending} 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>
<QueryPanel query={query}>
{(zones) => (
<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>
)}
</tr>
))}
</tbody>
</table>
</div>
{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}
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 })}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(zone)}
disabled={remove.isPending}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</QueryPanel>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete forward zone"
@@ -0,0 +1,225 @@
import { screen, waitFor, within } from "@testing-library/react";
import { CONFIG_PATH, DATABASE, MANAGED_FILE, contentArea, renderPage, renderRoute, stubApi } from "./testFixtures";
/**
* Authority is three-state, and the difference between the states is the whole
* point: an unresolved status is never treated as database mode, and file mode
* is a different rendering rather than the same forms with their controls
* turned off.
*/
const NEVER = new Promise<Response>(() => {});
/**
* Every button the file-mode configuration content is allowed to contain: the
* runtime actions, and the Retry an error surface offers. Anything else is a
* mutation control that should not have been rendered at all.
*/
const RUNTIME_BUTTONS = ["Update now", "Reload certificates", "Retry"];
afterEach(() => {
vi.unstubAllGlobals();
});
function mutationControls(): Element[] {
return [
...contentArea().querySelectorAll(
'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
),
];
}
function buttonLabels(): string[] {
return [...contentArea().querySelectorAll("button")].map((button) => (button.textContent ?? "").trim());
}
test("a pending status renders neither form nor definition list on Protection", async () => {
stubApi(DATABASE, { responses: { "GET /api/config/status": NEVER } });
await renderPage("/configuration/protection", "Protection");
expect(await screen.findByText(/which configuration source this server obeys/i)).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
expect(screen.queryByRole("button", { name: "Create" })).toBeNull();
});
test("a pending status still leaves the runtime actions live (R3-4)", async () => {
stubApi(DATABASE, { responses: { "GET /api/config/status": NEVER } });
await renderPage("/configuration/system", "System");
const reload = (await screen.findByRole("button", { name: "Reload certificates" })) as HTMLButtonElement;
expect(reload.disabled).toBe(false);
// The settings form is not drawn behind it.
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
});
test("a pending status renders neither form nor definition list on Resolution", async () => {
stubApi(DATABASE, { responses: { "GET /api/config/status": NEVER } });
await renderPage("/configuration/resolution", "Resolution");
expect(await screen.findByText(/which configuration source this server obeys/i)).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
expect(screen.queryByRole("button", { name: "Add upstream" })).toBeNull();
});
test("a failed status renders the error and a Retry, never editable forms", async () => {
stubApi(DATABASE, {
responses: { "GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }) },
});
await renderPage("/configuration/protection", "Protection");
await screen.findByText(/cannot say whether a file or the database owns/i);
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
});
test("a failed status renders the error and a Retry on Resolution too", async () => {
stubApi(DATABASE, {
responses: { "GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }) },
});
await renderPage("/configuration/resolution", "Resolution");
await screen.findByText(/cannot say whether a file or the database owns/i);
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
expect(screen.queryByRole("button", { name: "Add upstream" })).toBeNull();
});
test("a failed status keeps Update now enabled on the Sources tab", async () => {
stubApi(DATABASE, {
responses: { "GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }) },
});
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText(/cannot say whether a file or the database owns/i);
const update = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
expect(update.disabled).toBe(false);
});
test("a failed status is announced by the shell on a page that is not configuration", async () => {
stubApi(DATABASE, {
responses: {
"GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }),
"GET /api/overview?period=24h": {
period: "24h",
since: 0,
until: 86400,
bucket_seconds: 1800,
totals: { queries: 0, blocked: 0, clients: 0, avg_response_time_us: null },
buckets: [],
clients: [],
other: [],
types: [],
routes: [],
coverage: { complete: true, available_since: 0 },
},
},
});
renderRoute("/overview");
await waitFor(() => expect(screen.getByText(/file authority and pending restarts cannot be shown/i)).toBeTruthy());
});
test("database authority renders the editable groups form", async () => {
stubApi(DATABASE);
await renderPage("/configuration/protection", "Protection");
expect(await screen.findByRole("button", { name: "Create" })).toBeTruthy();
expect(screen.getByLabelText("New group")).toBeTruthy();
expect(screen.queryByText(/loaded from/i)).toBeNull();
});
test("file authority renders definitions with their exact ZON keys, not a form", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/protection", "Protection");
const note = await screen.findByText(/are loaded from/i);
expect(note.textContent).toContain(CONFIG_PATH);
const detail = await screen.findByText("Safe search");
expect(within(detail).getByText("groups[].safe_search")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Create" })).toBeNull();
});
test("file mode: Protection renders zero mutation controls (D12)", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/protection", "Protection");
await screen.findByText("groups[].safe_search");
expect(mutationControls()).toHaveLength(0);
for (const label of buttonLabels()) expect(RUNTIME_BUTTONS).toContain(label);
});
test("file mode: the Sources tab shows the catalogue with its key and only Update now", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText("blocklist_sources");
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual(["Update now"]);
});
test("file mode: the catalogue keeps provenance and both skipped counters", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText("blocklist_sources");
// Trackers is the suggested source, and the one with skipped lines: hiding
// either fact under file authority would hide a parse failure.
const row = screen.getByText("Trackers").closest("tr")!;
expect(within(row).getByText("Suggested")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
const cells = [...row.querySelectorAll("td")].map((cell) => cell.textContent);
expect(cells).toContain("3");
expect(cells).toContain("4");
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
});
test("file mode: the Resolution upstream pool is a table with no controls", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/resolution", "Resolution");
await screen.findByText("upstreams");
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual([]);
});
test("file mode: the Records tab is a table with no controls", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/resolution?tab=records", "Resolution");
await screen.findByText("local_records");
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual([]);
});
test("file mode: the Forward zones tab is a table with no controls", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/resolution?tab=zones", "Resolution");
await screen.findByText("forward_zones");
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual([]);
});
test("file mode: System renders scalars as definitions and keeps Reload certificates", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/system", "System");
const retention = await screen.findByText("Retention days");
expect(within(retention).getByText("logging.retention_days")).toBeTruthy();
expect(mutationControls()).toHaveLength(0);
expect(buttonLabels()).toEqual(["Reload certificates"]);
});
test("file mode: authentication is a derived status with no invented key (D13)", async () => {
stubApi(MANAGED_FILE);
await renderPage("/configuration/system", "System");
const auth = await screen.findByText("Authentication");
expect(auth.textContent).toBe("Authentication");
expect(within(auth).queryByText(/web\.auth_enabled/)).toBeNull();
expect(screen.queryByText("web.auth_enabled")).toBeNull();
});
@@ -0,0 +1,49 @@
import { useQuery } from "@tanstack/react-query";
import { configStatusQuery } from "@/lib/queries";
import type { ConfigStatus } from "@/lib/types";
/**
* Configuration authority as the UI must treat it: three states, never two.
*
* `undefined` is not database mode. Until `/api/config/status` answers, the
* running process may be file-managed, and a form rendered on that guess
* invites edits the server will reject. So a page shows neither an editable
* form nor a definition list until the query resolves, and a failed query is
* an explicit error with a way to retry.
*/
export type Authority =
| { state: "pending" }
| { state: "failed"; error: unknown; retry: () => void }
| { state: "resolved"; status: ConfigStatus };
/**
* The running server's configuration status. Every page may call this it is
* the shared `["configStatus"]` key, so one subscription serves them all from
* cache.
*/
export function useAuthority(): Authority {
const query = useQuery(configStatusQuery());
if (query.isPending) return { state: "pending" };
if (query.isError) {
return {
state: "failed",
error: query.error,
retry: () => void query.refetch(),
};
}
return { state: "resolved", status: query.data };
}
/**
* True unless the server has said the database owns the configuration.
*
* The lock is global and it fails closed: pending, failed and `managed_file`
* all read as locked, because only a resolved database authority proves a
* configuration mutation can succeed. Runtime actions pause, update now,
* reload certificates, deleting an observed client, login and logout do not
* consult it; they work under every authority.
*/
export function useReadOnlyConfig(): boolean {
const authority = useAuthority();
return !(authority.state === "resolved" && authority.status.authority === "database");
}
@@ -0,0 +1,98 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { createAppRouter } from "@/routes";
import { validateGroupId, validateProtectionSearch, validateResolutionSearch } from "@/features/configuration/search";
import { DATABASE, renderPage, stubApi } from "./testFixtures";
/**
* The configuration URL is the applied state: which page, which tab, which
* group. A view of a page is a link, and the back button walks what the reader
* chose rather than what the page corrected on their behalf.
*/
afterEach(() => {
vi.unstubAllGlobals();
});
test("the six resource routes are gone, with no alias left behind", () => {
const paths = new Set(Object.keys(createAppRouter().routesByPath));
for (const gone of ["/groups", "/blocklists", "/rules", "/local-dns", "/upstreams", "/settings"]) {
expect(paths.has(gone)).toBe(false);
}
for (const kept of ["/configuration/protection", "/configuration/resolution", "/configuration/system"]) {
expect(paths.has(kept)).toBe(true);
}
// No landing route either: `/configuration` is not a page.
expect(paths.has("/configuration")).toBe(false);
});
test("an unknown tab falls back to the page default rather than an empty panel", () => {
expect(validateProtectionSearch({ tab: "upstreams" }).tab).toBe("groups");
expect(validateProtectionSearch({}).tab).toBe("groups");
expect(validateResolutionSearch({ tab: "sources" }).tab).toBe("upstreams");
expect(validateResolutionSearch({ tab: "zones" }).tab).toBe("zones");
});
test("a group id is a positive integer or nothing", () => {
expect(validateGroupId(3)).toBe(3);
expect(validateGroupId(0)).toBeUndefined();
expect(validateGroupId(-1)).toBeUndefined();
expect(validateGroupId(1.5)).toBeUndefined();
expect(validateGroupId("2")).toBeUndefined();
});
test("an unknown group falls back to the first one and rewrites the URL in place", async () => {
stubApi(DATABASE);
const router = await renderPage("/configuration/protection?group=99", "Protection");
await waitFor(() => expect(router.state.location.search.group).toBe(1));
await screen.findByRole("heading", { name: "default", level: 2 });
// `replace`: the corrected address did not become a place to go back to.
expect(router.history.length).toBe(1);
});
test("selecting a group pushes a history entry the back button walks", async () => {
stubApi(DATABASE);
const router = await renderPage("/configuration/protection", "Protection");
await waitFor(() => expect(router.state.location.search.group).toBe(1));
fireEvent.click(screen.getByRole("link", { name: "kids" }));
await waitFor(() => expect(router.state.location.search.group).toBe(2));
await screen.findByRole("heading", { name: "kids", level: 2 });
act(() => router.history.back());
await waitFor(() => expect(router.state.location.search.group).toBe(1));
});
test("the selected group survives a tab change, and the change is a history entry", async () => {
stubApi(DATABASE);
const router = await renderPage("/configuration/protection?group=2", "Protection");
await screen.findByRole("heading", { name: "kids", level: 2 });
fireEvent.click(screen.getByRole("tab", { name: "Sources" }));
await screen.findByRole("button", { name: "Update now" });
expect(router.state.location.search).toEqual({ tab: "sources", group: 2 });
act(() => router.history.back());
await waitFor(() => expect(router.state.location.search.tab).toBe("groups"));
expect(router.state.location.search.group).toBe(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
});
test("the Resolution tab is URL state too", async () => {
stubApi(DATABASE);
const router = await renderPage("/configuration/resolution", "Resolution");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await waitFor(() => expect(router.state.location.search.tab).toBe("zones"));
await screen.findByRole("button", { name: "Add zone" });
});
test("the loaders are started, not awaited: the page renders while a collection is in flight", async () => {
// Groups never answers. If the loader were awaited the navigation would hang
// and neither the heading nor the tabs would ever paint.
stubApi(DATABASE, { responses: { "GET /api/groups": new Promise<Response>(() => {}) } });
await renderPage("/configuration/protection", "Protection");
expect(screen.getByRole("tab", { name: "Sources" })).toBeTruthy();
expect(await screen.findByText("Loading…")).toBeTruthy();
});
@@ -0,0 +1,59 @@
/**
* The configuration pages' URL state, validated as pure functions so each
* route's `validateSearch` stays a one-liner and every rejection is testable
* without a router.
*
* A tab that is not one of the page's tabs falls back to that page's default:
* a hand-typed `?tab=upstreams` on Protection opens Protection's first tab
* rather than an empty panel.
*/
export const PROTECTION_TABS = ["groups", "sources"] as const;
export type ProtectionTab = (typeof PROTECTION_TABS)[number];
export const RESOLUTION_TABS = ["upstreams", "records", "zones"] as const;
export type ResolutionTab = (typeof RESOLUTION_TABS)[number];
/**
* Both fields are optional so a link to the page need not spell out a tab it
* does not care about the same shape Overview's `period` uses. Validation
* still resolves an unknown tab to the page default, so a *rendered* page
* always has one.
*/
export interface ProtectionSearch {
tab?: ProtectionTab;
/**
* The selected group. Absent until the page resolves one, which it then
* writes back so a view of the page is a link.
*/
group?: number;
}
export interface ResolutionSearch {
tab?: ResolutionTab;
}
function validateTab<T extends string>(options: readonly T[], value: unknown, fallback: T): T {
return options.includes(value as T) ? (value as T) : fallback;
}
/**
* A group row id. Ids are positive integers, so anything else a fraction, a
* string, a zero is not an id that could ever exist and becomes `undefined`.
* An id that is well-formed but unknown is a different case: the page has to
* load the groups before it can tell, so it falls back there, not here.
*/
export function validateGroupId(value: unknown): number | undefined {
return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined;
}
export function validateProtectionSearch(search: Record<string, unknown>): ProtectionSearch {
return {
tab: validateTab(PROTECTION_TABS, search["tab"], "groups"),
group: validateGroupId(search["group"]),
};
}
export function validateResolutionSearch(search: Record<string, unknown>): ResolutionSearch {
return { tab: validateTab(RESOLUTION_TABS, search["tab"], "upstreams") };
}
@@ -0,0 +1,132 @@
import type { Settings } from "@/lib/types";
/**
* The settings registry. One list drives both renderings: the editable form
* builds a control per field, the file-mode page builds a definition per
* field, and neither can drift from the other or from `Settings`.
*/
export interface FieldDef<S extends keyof Settings> {
key: keyof Settings[S] & string;
kind: "number" | "text" | "boolean" | readonly string[];
}
export interface SectionDef<S extends keyof Settings> {
section: S;
title: string;
fields: readonly FieldDef<S>[];
}
/** Binds each section's field keys to that section's Settings type at definition. */
function defineSection<S extends keyof Settings>(def: SectionDef<S>): SectionDef<S> {
return def;
}
/** The registry read back as a heterogeneous list, once the per-section binding has been proven. */
export type AnyFieldDef = { [S in keyof Settings]: FieldDef<S> }[keyof Settings];
export type AnySectionDef = { [S in keyof Settings]: SectionDef<S> }[keyof Settings];
/**
* A section's values as a string-keyed view. The keys are proven against
* `Settings[S]` where each section is defined; iterating the heterogeneous
* registry loses that correlation, so consumption widens here in one place.
*/
export function sectionValues(settings: Settings, section: keyof Settings): Record<string, unknown> {
return settings[section] as Record<string, unknown>;
}
const TLS_FIELDS: readonly FieldDef<"doh_server" | "dot_server">[] = [
{ key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" },
{ key: "port", kind: "number" },
{ key: "cert_path", kind: "text" },
{ key: "key_path", kind: "text" },
];
export const SECTIONS: readonly AnySectionDef[] = [
defineSection({
section: "upstream",
title: "Upstream",
fields: [
{ key: "attempt_timeout_ms", kind: "number" },
{ key: "read_timeout_ms", kind: "number" },
{ key: "total_timeout_ms", kind: "number" },
],
}),
defineSection({
section: "dns",
title: "DNS",
fields: [
{ key: "bind_ipv4", kind: "text" },
{ key: "bind_ipv6", kind: "text" },
{ key: "port", kind: "number" },
{ key: "rate_limit", kind: "number" },
{ key: "rate_window_seconds", kind: "number" },
],
}),
defineSection({
section: "blocking",
title: "Blocking",
fields: [
{ key: "response", kind: ["zero", "nxdomain"] },
{ key: "ttl", kind: "number" },
],
}),
defineSection({
section: "cache",
title: "Cache",
fields: [
{ key: "size", kind: "number" },
{ key: "negative_ttl_max", kind: "number" },
],
}),
defineSection({
section: "web",
title: "Web",
fields: [
{ key: "enabled", kind: "boolean" },
{ key: "bind", kind: "text" },
{ key: "port", kind: "number" },
{ key: "session_ttl_hours", kind: "number" },
{ key: "api_rate_limit_per_min", kind: "number" },
{ key: "api_localhost_exempt", kind: "boolean" },
{ key: "sse_max_connections_per_ip", kind: "number" },
{ key: "trusted_proxies", kind: "text" },
],
}),
defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }),
defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }),
defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }),
defineSection({
section: "logging",
title: "Logging",
fields: [
{ key: "level", kind: ["error", "warn", "info", "debug"] },
{ key: "retention_days", kind: "number" },
{ key: "query_log_buffer_max", kind: "number" },
{ key: "query_log_flush_interval_s", kind: "number" },
{ key: "hide_domains", kind: "boolean" },
{ key: "hide_client_ips", kind: "boolean" },
{ key: "output", kind: ["stderr", "syslog", "file"] },
{ key: "file_path", kind: "text" },
{ key: "max_size_mb", kind: "number" },
{ key: "max_files", kind: "number" },
],
}),
defineSection({
section: "disk",
title: "Disk",
fields: [
{ key: "min_free_mb", kind: "number" },
{ key: "warn_free_mb", kind: "number" },
],
}),
defineSection({
section: "blocklist_update",
title: "Blocklist Update",
fields: [
{ key: "enabled", kind: "boolean" },
{ key: "interval_hours", kind: "number" },
],
}),
];
@@ -0,0 +1,8 @@
import { sameSet } from "./sourceSet";
test("sameSet compares regardless of order", () => {
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
expect(sameSet([], [])).toBe(true);
expect(sameSet([1, 2], [1, 2, 3])).toBe(false);
expect(sameSet([1, 2], [1, 4])).toBe(false);
});
@@ -1,8 +1,3 @@
export function toggleSource(ids: number[], id: number): number[] {
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
return [...ids, id].sort((a, b) => a - b);
}
export function sameSet(a: number[], b: number[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort((x, y) => x - y);
+142
View File
@@ -0,0 +1,142 @@
/**
* The vocabulary the three configuration pages share. Both renderings the
* editable forms and the file-mode definition lists sit on the same panels
* and headings, so the page keeps its shape when authority changes.
*/
import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex";
export const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
maxWidth: "48rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
panel: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
panelHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
/** The collection's own key in the configuration file, beside its heading. */
panelKey: {
marginLeft: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
note: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** The file-mode page note: where edits happen, and what applies them. */
fileNote: {
marginTop: "1rem",
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,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
empty: {
marginTop: "0.5rem",
color: colors.textMuted,
},
/** Master/detail: the group list beside the selected group. */
split: {
marginTop: "1rem",
display: "grid",
gap: "1rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 900px)": "16rem 1fr",
},
alignItems: "start",
},
masterList: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
masterLink: {
display: "block",
borderRadius: "0.25rem",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
textDecorationLine: "none",
color: colors.textSecondary,
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
},
masterLinkActive: {
backgroundColor: colors.surfaceHover,
color: colors.text,
fontWeight: 500,
},
actionRow: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
success: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
/** The authority error state: no forms, no definition list, one way forward. */
blocked: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
pending: {
marginTop: "1rem",
color: colors.textMuted,
},
restartTag: {
marginLeft: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
@@ -0,0 +1,272 @@
/**
* The fixtures and the stubbed API the configuration tests share.
*
* All three pages read the same handful of collections plus
* `/api/config/status`, so a per-file copy of the stub would be five copies of
* one contract drifting apart. Test-only: nothing in `src` imports it, so it
* never reaches the bundle.
*/
import { 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 { health } from "@/lib/healthFixture";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { sample_get_settings } from "@/lib/contractSamples.gen";
import type { ConfigStatus, Settings } from "@/lib/types";
export const CONFIG_PATH = "/etc/nxdns/config.zon";
export const RECONCILED_AT = 1754899200;
export const DATABASE: ConfigStatus = {
authority: "database",
path: null,
reconciled_at: null,
restart_pending: false,
};
export const MANAGED_FILE: ConfigStatus = {
authority: "managed_file",
path: CONFIG_PATH,
reconciled_at: RECONCILED_AT,
restart_pending: false,
};
/**
* The keys `/api/settings` still reports as restart-required, taken from the
* committed contract sample so a server-side change to the set fails the tests
* that pin it rather than passing against a stale copy.
*/
export const RESTART_REQUIRED_KEYS: readonly string[] = sample_get_settings.restart_required;
export function baseSettings(): Settings {
return {
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 },
web: {
enabled: true,
bind: "127.0.0.1",
port: 8080,
session_ttl_hours: 24,
api_rate_limit_per_min: 60,
api_localhost_exempt: true,
sse_max_connections_per_ip: 2,
trusted_proxies: "",
auth_enabled: true,
},
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
edns: { ecs_mode: "strip" },
logging: {
level: "info",
retention_days: 30,
query_log_buffer_max: 10000,
query_log_flush_interval_s: 60,
hide_domains: false,
hide_client_ips: false,
output: "stderr",
file_path: "",
max_size_mb: 50,
max_files: 3,
},
disk: { min_free_mb: 100, warn_free_mb: 500 },
blocklist_update: { enabled: true, interval_hours: 24 },
};
}
export const GROUPS = [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
];
export const BLOCKLISTS = [
{
id: 1,
url: "https://example.com/ads.txt",
name: "Ads",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 100,
wildcard_count: 2,
exception_count: 1,
skipped_regex_count: 0,
skipped_unsupported_count: 0,
checksum: null,
},
{
id: 2,
url: "https://example.com/trackers.txt",
name: "Trackers",
enabled: false,
is_suggested: true,
last_updated: 1700000000,
domain_count: 50,
wildcard_count: 0,
exception_count: 0,
skipped_regex_count: 3,
skipped_unsupported_count: 4,
checksum: null,
},
];
export const 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: "*.social.example",
kind: "wildcard",
action: "block",
created_at: 1700000100,
},
];
export const CLIENTS = [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "tablet.lan",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
];
export const UPSTREAMS = [
{ id: 1, url: "udp://1.1.1.1:53", priority: 100, enabled: true, tls_name: "" },
{ id: 2, url: "tls://9.9.9.9:853", priority: 200, enabled: false, tls_name: "dns.quad9.net" },
];
export const LOCAL_RECORDS = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.5", ttl: 300 }];
export const FORWARD_ZONES = [{ id: 1, zone: "lan.home", resolver: "udp://192.168.1.1:53" }];
export interface Call {
url: string;
method: string;
body: unknown;
}
export interface StubOptions {
/**
* Overrides and additions, keyed `"GET /api/x"`. A `Response` is used
* verbatim; a function is called per request, so a test can change what the
* server says between two reads of the same endpoint.
*/
responses?: Record<string, unknown>;
/**
* First refusal on every non-GET; return null to fall through to the default
* echo. An unsettled promise holds the mutation in flight, which is how a
* test observes a pending control.
*/
onWrite?: (call: Call) => Response | Promise<Response> | null;
}
function defaultResponses(status: ConfigStatus): Record<string, unknown> {
return {
"GET /api/config/status": status,
"GET /api/health": health(),
"GET /api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
"GET /api/groups": { groups: GROUPS },
"GET /api/groups/1/sources": { source_ids: [1] },
"GET /api/groups/2/sources": { source_ids: [] },
"GET /api/blocklists": { blocklists: BLOCKLISTS },
"GET /api/rules": { rules: RULES },
"GET /api/clients": { clients: CLIENTS },
"GET /api/client-prefixes": { client_prefixes: [] },
"GET /api/upstreams": { upstreams: UPSTREAMS },
"GET /api/local-records": { local_records: LOCAL_RECORDS },
"GET /api/forward-zones": { forward_zones: FORWARD_ZONES },
"GET /api/settings": { settings: baseSettings(), restart_required: RESTART_REQUIRED_KEYS },
};
}
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
/** Installs the stub and returns the list every non-GET request is appended to. */
export function stubApi(status: ConfigStatus = DATABASE, options: StubOptions = {}): Call[] {
const map = { ...defaultResponses(status), ...options.responses };
const calls: Call[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
const key = `${method} ${url}`;
const configured = map[key];
// A promise is handed back unsettled on purpose: it is how a test holds a
// query in its pending state for as long as it needs to.
if (configured instanceof Promise) return configured;
if (configured instanceof Response) return configured.clone();
if (typeof configured === "function") return json((configured as () => unknown)());
if (method !== "GET") {
calls.push({ url, method, body });
const override = options.onWrite?.({ url, method, body });
if (override !== null && override !== undefined) return override;
if (configured !== undefined) return json(configured);
if (method === "DELETE") return new Response(null, { status: 204 });
return json(body ?? {});
}
if (configured === undefined) return json({ error: `not stubbed: ${key}` }, 404);
return json(configured);
}),
);
return calls;
}
export function renderRoute(route: string) {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
export async function renderPage(route: string, heading: string) {
const router = renderRoute(route);
await screen.findByRole("heading", { name: heading, level: 1 });
return router;
}
/** The configuration page's own content, excluding the shell chrome around it. */
export function contentArea(): HTMLElement {
const main = document.querySelector("main");
if (main === null) throw new Error("no main element");
return main;
}
@@ -1,252 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
/** Wall clock at import; the upstream fixtures date their failures against it. */
const NOW_S = Math.floor(Date.now() / 1000);
const RESPONSES: Record<string, unknown> = {
"/api/stats?period=24h": {
period: "24h",
since: 0,
until: 86400,
queries: 1000,
blocked: 250,
cached: 100,
clients: 7,
avg_response_time_us: 2345,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=24h": {
period: "24h",
since: 0,
until: 86400,
bucket_seconds: 1800,
buckets: [
{ ts: 0, queries: 60, blocked: 20, cached: 10 },
{ ts: 1800, queries: 40, blocked: 0, cached: 0 },
{ ts: 3600, queries: 0, blocked: 0, cached: 0 },
],
coverage: { complete: true, available_since: 0 },
},
"/api/stats?period=1h": {
period: "1h",
since: 0,
until: 3600,
queries: 12,
blocked: 3,
cached: 0,
clients: 2,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=1h": {
period: "1h",
since: 0,
until: 3600,
bucket_seconds: 60,
buckets: [],
coverage: { complete: true, available_since: 0 },
},
"/api/health": {
status: "degraded",
disk: {
state: "warn",
free_bytes: 400 * 1024 * 1024,
db_bytes: 12 * 1024 * 1024,
log_bytes: 2048,
sample_failures: 0,
},
upstreams: { available: 1, total: 2 },
queries_dropped: 5,
writer_failed: false,
refreshes_gated: 0,
snapshot_generation: 3,
diagnostics: { state: "recording", active_warnings: 1, active_errors: 0 },
},
"/api/upstream/health?period=24h": {
period: "24h",
since: NOW_S - 86_400,
until: NOW_S,
available: 1,
total: 2,
complete: true,
upstreams: [
{
url: "https://dns.example/dns-query",
enabled: true,
available: false,
period: {
attempts: 100,
successes: 90,
failures: 10,
success_rate: 0.9,
// 3h30m before the fixture's now, far from a unit boundary.
last_failure_at: NOW_S - 12_600,
last_failure_error: "timeout",
},
},
{
url: "udp://9.9.9.9:53",
enabled: true,
available: true,
period: {
attempts: 100,
successes: 100,
failures: 0,
success_rate: 1,
last_failure_at: null,
last_failure_error: null,
},
},
],
},
"/api/upstream/health?period=1h": {
period: "1h",
since: NOW_S - 3600,
until: NOW_S,
available: 1,
total: 1,
complete: true,
upstreams: [
{
url: "https://dns.example/dns-query",
enabled: true,
available: true,
period: {
attempts: 7,
successes: 6,
failures: 1,
success_rate: 6 / 7,
last_failure_at: NOW_S - 300,
last_failure_error: "timeout",
},
},
],
},
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
// Endpoints forced to fail with a 4xx, which the query client does not retry.
let failing: Set<string>;
beforeEach(() => {
failing = new Set();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (failing.has(url)) {
return new Response(JSON.stringify({ error: "upstream health unavailable" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderDashboard() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
}
test("dashboard renders stats, chart, disk card, upstream table and health banners", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByText("250")).toBeTruthy();
expect(screen.getByText("25.0%")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("2.3 ms")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.getByText("warn")).toBeTruthy();
expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0);
expect(screen.getByText("12.0 MiB")).toBeTruthy();
expect(screen.getByText("2.0 KiB")).toBeTruthy();
const alerts = screen.getAllByRole("alert");
expect(alerts.some((alert) => /disk space low/i.test(alert.textContent ?? ""))).toBe(true);
expect(alerts.some((alert) => /5 queries dropped/i.test(alert.textContent ?? ""))).toBe(true);
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
expect(screen.getByText("90.0%")).toBeTruthy();
expect(screen.getByText("100.0%")).toBeTruthy();
expect(screen.getByText("1/2 available")).toBeTruthy();
});
test("live state is labeled on its own card, not by a section that disowns the picker", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.queryByRole("region", { name: "Right now" })).toBeNull();
expect(screen.queryByText("Right now")).toBeNull();
expect(screen.queryByText("Snapshot state; the period above does not apply.")).toBeNull();
});
test("the period picker rescopes the upstream table", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
await screen.findByText("90.0%");
fireEvent.click(screen.getByRole("button", { name: "1h" }));
await screen.findByText("85.7%");
expect(screen.getByRole("columnheader", { name: "Selected period · 1h" })).toBeTruthy();
expect(screen.queryByText("90.0%")).toBeNull();
});
test("period picker refetches stats and shows the empty chart state", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
fireEvent.click(screen.getByRole("button", { name: "1h" }));
await screen.findByText("12");
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
await screen.findByText("No queries in this period.");
expect(screen.getByText("—", { selector: "span" })).toBeTruthy();
});
test("one failing endpoint degrades its own widget on cold navigation", async () => {
failing.add("/api/upstream/health?period=24h");
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
// The page renders; only the upstream widget carries the error.
await screen.findByText("upstream health unavailable");
expect(screen.queryByText("Something went wrong")).toBeNull();
expect(screen.queryByText("Request failed (400)")).toBeNull();
expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
});
@@ -1,173 +0,0 @@
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards";
import TimeseriesChart from "./TimeseriesChart";
import UpstreamHealthTable from "./UpstreamHealthTable";
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
titleRow: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
period: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
/** Dynamic: the caller sizes the placeholder to the widget it stands in for. */
skeletonHeight: (height: number) => ({ height }),
skeleton: {
borderRadius: "0.25rem",
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
},
},
/** The chart takes two thirds beside the storage card from `lg`, one column below. */
panelGrid: {
display: "grid",
gap: "1rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 1024px)": "2fr 1fr",
},
},
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
});
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
{...stylex.props(
styles.period,
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
>
{option}
</button>
))}
</div>
);
}
function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
}
export default function DashboardPage() {
const [period, setPeriod] = useState<Period>("24h");
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
const health = useQuery(healthQuery());
const upstreamHealth = useQuery({ ...upstreamHealthQuery(period), placeholderData: keepPreviousData });
return (
<section {...stylex.props(styles.page)}>
<div {...stylex.props(styles.titleRow)}>
<h1 {...stylex.props(styles.heading)}>Dashboard</h1>
<PeriodPicker period={period} onChange={setPeriod} />
</div>
{health.data !== undefined && <HealthBanners health={health.data} />}
{stats.isError ? (
<InlineError error={stats.error} onRetry={() => void stats.refetch()} />
) : stats.data === undefined ? (
<Skeleton height={76} />
) : (
<StatCards stats={stats.data} />
)}
{/* One notice for the period: the chart is judged against the same
aligned lower bound as the totals, so it would say the same thing. */}
{stats.data !== undefined && <CoverageNotice coverage={stats.data.coverage} />}
<div {...stylex.props(styles.panelGrid)}>
<section {...stylex.props(styles.panel)}>
<h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
{timeseries.isError ? (
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
) : timeseries.data === undefined ? (
<Skeleton height={240} />
) : (
<TimeseriesChart data={timeseries.data} />
)}
</section>
{health.data === undefined ? <Skeleton height={160} /> : <DiskCard disk={health.data.disk} />}
</div>
{upstreamHealth.isError ? (
<InlineError error={upstreamHealth.error} onRetry={() => void upstreamHealth.refetch()} />
) : upstreamHealth.data === undefined ? (
<Skeleton height={120} />
) : (
<UpstreamHealthTable health={upstreamHealth.data} />
)}
</section>
);
}
-99
View File
@@ -1,99 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
heading: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
badge: {
borderRadius: "0.25rem",
paddingInline: "0.5rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/**
* The badge fills are their own three-step scale, not the `danger`/`warn`
* banner tokens: they read as a tinted chip against a raised card, where a
* banner fill would be too heavy.
*/
ok: {
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
warn: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
critical: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
row: {
display: "flex",
justifyContent: "space-between",
},
term: {
color: colors.textMuted,
},
});
function stateStyle(state: Health["disk"]["state"]) {
if (state === "critical") return styles.critical;
return state === "warn" ? styles.warn : styles.ok;
}
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
return (
<section {...stylex.props(styles.card)}>
{/* Live state, unlike the ranged widgets around it; the title says so
rather than a section rule the picker would have to disown. */}
<h2 {...stylex.props(styles.heading)}>
Storage now
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
</h2>
<dl {...stylex.props(styles.list)}>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Free</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Database</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Logs</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
</div>
</dl>
</section>
);
}
@@ -1,68 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
stack: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
banner: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
warn: {
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
},
critical: {
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
color: colors.dangerText,
},
});
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
return (
<p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
{children}
</p>
);
}
export default function HealthBanners({ health }: { health: Health }) {
const banners: React.ReactNode[] = [];
if (health.disk.state !== "ok") {
banners.push(
<Banner key="disk" tone={health.disk.state === "critical" ? "critical" : "warn"}>
{health.disk.state === "critical"
? `Disk critically low: ${formatBytes(health.disk.free_bytes)} free. Blocklist updates and log flushes are stopped.`
: `Disk space low: ${formatBytes(health.disk.free_bytes)} free.`}
</Banner>,
);
}
if (health.writer_failed) {
banners.push(
<Banner key="writer" tone="critical">
Query log writer failed; new queries are not being persisted.
</Banner>,
);
}
if (health.queries_dropped > 0) {
banners.push(
<Banner key="dropped" tone="warn">
{health.queries_dropped.toLocaleString()} queries dropped from the log buffer.
</Banner>,
);
}
if (banners.length === 0) return null;
return <div {...stylex.props(styles.stack)}>{banners}</div>;
}
@@ -1,85 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
},
},
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
value: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
detail: {
marginLeft: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
function percentOf(part: number, total: number): string | null {
if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`;
}
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
return (
<div {...stylex.props(styles.card)}>
<dt {...stylex.props(styles.label)}>{label}</dt>
<dd>
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd>
</div>
);
}
export default function StatCards({ stats }: { stats: StatsTotals }) {
return (
<dl {...stylex.props(styles.grid)}>
<Card label="Queries" value={numberFormat.format(stats.queries)} />
<Card
label="Blocked"
value={numberFormat.format(stats.blocked)}
detail={percentOf(stats.blocked, stats.queries)}
/>
<Card
label="Cached"
value={numberFormat.format(stats.cached)}
detail={percentOf(stats.cached, stats.queries)}
/>
<Card label="Clients" value={numberFormat.format(stats.clients)} />
<Card
label="Avg response"
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
/>
</dl>
);
}
@@ -1,52 +0,0 @@
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,322 +0,0 @@
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
// modes (Tailwind red-500 / blue-500 / emerald-600; same hex light and dark).
const SERIES = [
{ key: "blocked", label: "Blocked", color: "#ef4444" },
{ key: "cached", label: "Cached", color: "#059669" },
{ key: "other", label: "Other", color: "#3b82f6" },
] as const;
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);
useEffect(() => {
const el = ref.current;
if (el === null) return;
setWidth(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, width];
}
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
function formatTick(ts: number, bucketSeconds: number): string {
const date = new Date(ts * 1000);
if (bucketSeconds >= 86_400) {
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
}
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
}
function barSummary(bar: BarLayout): string {
return `${formatTime(bar.bucket.ts)}: ${bar.bucket.queries} queries, ${bar.bucket.blocked} blocked, ${bar.bucket.cached} cached`;
}
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 {...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} {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
/>
{series.label}
</dt>
<dd {...stylex.props(shared.tabularNums)}>
{series.key === "other" ? bar.other : bar.bucket[series.key]}
</dd>
</div>
))}
</dl>
</div>
);
}
export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
const [containerRef, measuredWidth] = useContainerWidth();
const [hovered, setHovered] = useState<number | null>(null);
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
return (
<div ref={containerRef} {...stylex.props(styles.empty)}>
No queries in this period.
</div>
);
}
const layout = layoutTimeseries(data.buckets, width, CHART_HEIGHT);
const baseline = layout.plot.y + layout.plot.height;
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
return (
<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`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
onMouseLeave={() => setHovered(null)}
>
{layout.yTicks.map((tick) => (
<g key={tick.value}>
<line
x1={layout.plot.x}
x2={layout.plot.x + layout.plot.width}
y1={tick.y}
y2={tick.y}
{...stylex.props(styles.gridLine)}
/>
<text
x={layout.plot.x - 6}
y={tick.y}
textAnchor="end"
dominantBaseline="middle"
{...stylex.props(styles.axisLabel, shared.tabularNums)}
>
{compact.format(tick.value)}
</text>
</g>
))}
<line
x1={layout.plot.x}
x2={layout.plot.x + layout.plot.width}
y1={baseline}
y2={baseline}
{...stylex.props(styles.axisLine)}
/>
{layout.xTicks.map((tick) => (
<text
key={tick.ts}
x={tick.x}
y={baseline + 14}
textAnchor="middle"
{...stylex.props(styles.axisLabel)}
>
{formatTick(tick.ts, data.bucket_seconds)}
</text>
))}
{layout.bars.map((bar, i) => (
<g key={bar.bucket.ts} opacity={hovered === null || hovered === i ? 1 : 0.55}>
{SERIES.map((series) => {
const rect = bar.segments[series.key];
if (rect.height <= 0) return null;
return (
<rect
key={series.key}
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
fill={series.color}
strokeWidth={rect.width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/>
);
})}
</g>
))}
{layout.bars.map((bar, i) => (
<rect
key={bar.bucket.ts}
x={bar.slot.x}
y={bar.slot.y}
width={bar.slot.width}
height={bar.slot.height}
fill="transparent"
onMouseEnter={() => setHovered(i)}
>
<title>{barSummary(bar)}</title>
</rect>
))}
</svg>
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
<ul {...stylex.props(styles.legend)}>
{SERIES.map((series) => (
<li key={series.key} {...stylex.props(styles.legendItem)}>
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
/>
{series.label}
</li>
))}
</ul>
<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>
</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>
);
}
@@ -1,165 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
import UpstreamHealthTable from "./UpstreamHealthTable";
const NOW_S = 1_700_000_000;
const ZERO: UpstreamPeriodStats = {
attempts: 0,
successes: 0,
failures: 0,
success_rate: null,
last_failure_at: null,
last_failure_error: null,
};
function period(overrides: Partial<UpstreamPeriodStats> = {}): UpstreamPeriodStats {
return {
attempts: 100,
successes: 90,
failures: 10,
success_rate: 0.9,
last_failure_at: NOW_S - 12_600,
last_failure_error: "Timeout",
...overrides,
};
}
function entry(overrides: Partial<UpstreamHealthEntry> = {}): UpstreamHealthEntry {
return {
url: "https://dns.example/dns-query",
enabled: true,
available: true,
period: period(),
...overrides,
};
}
function renderTable(upstreams: UpstreamHealthEntry[], overrides: Partial<UpstreamHealth> = {}) {
const health: UpstreamHealth = {
period: "24h",
since: NOW_S - 86_400,
until: NOW_S,
available: upstreams.filter((upstream) => upstream.available).length,
total: upstreams.length,
complete: true,
upstreams,
...overrides,
};
render(<UpstreamHealthTable health={health} />);
}
function rowOf(url: string): HTMLElement {
const cell = screen.getByText(url);
const row = cell.closest("tr");
if (row === null) throw new Error(`no row for ${url}`);
return row;
}
test("the ranged columns sit under a header naming the selected period", () => {
renderTable([entry()]);
expect(screen.getByRole("columnheader", { name: "Selected period · 24h" })).toBeTruthy();
for (const name of ["Upstream", "Status now", "Attempts", "Failures", "Success rate"]) {
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
}
// The unranged yes/no pair the ranged table replaced.
expect(screen.queryByRole("columnheader", { name: "Enabled" })).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Available" })).toBeNull();
});
test("failure detail is the Diagnostics page's job; the card never shows it", () => {
renderTable([entry()]);
expect(screen.queryByRole("columnheader", { name: "Last failure" })).toBeNull();
expect(screen.queryByText(/Timeout/)).toBeNull();
expect(screen.queryByText(/ago$/)).toBeNull();
});
test("status now is one word from live state, not from the window", () => {
renderTable([
entry({ url: "https://a.example/dns-query" }),
entry({ url: "https://b.example/dns-query", available: false }),
entry({ url: "https://c.example/dns-query", enabled: false, available: false }),
]);
expect(within(rowOf("https://a.example/dns-query")).getByText("Available")).toBeTruthy();
expect(within(rowOf("https://b.example/dns-query")).getByText("Backing off")).toBeTruthy();
expect(within(rowOf("https://c.example/dns-query")).getByText("Disabled")).toBeTruthy();
});
test("a window with no attempts renders an em-dash and never a perfect rate", () => {
renderTable([entry({ period: ZERO })]);
const cells = within(rowOf("https://dns.example/dns-query")).getAllByRole("cell");
expect(cells.map((cell) => cell.textContent)).toEqual([
"https://dns.example/dns-query",
"Available",
"0",
"0",
"—",
]);
expect(screen.queryByText("100.0%")).toBeNull();
expect(screen.queryByText("0.0%")).toBeNull();
});
test("the card says so when every upstream was idle in the window", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry({ period: ZERO })]);
expect(screen.getByText("No upstream attempts in this period.")).toBeTruthy();
});
test("one upstream with attempts keeps the idle message away", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry()]);
expect(screen.queryByText("No upstream attempts in this period.")).toBeNull();
});
test("an incomplete window carries a note; a complete one claims nothing", () => {
renderTable([entry()], { complete: false });
expect(screen.getByText(/history incomplete/i)).toBeTruthy();
});
test("a complete window shows no completeness text at all", () => {
renderTable([entry()], { complete: true });
expect(screen.queryByText(/history incomplete/i)).toBeNull();
expect(screen.queryByText(/complete/i)).toBeNull();
});
test("an empty pool says so instead of drawing a table", () => {
renderTable([]);
expect(screen.getByText("No upstreams configured.")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});
test("a rate a hair under perfect never rounds up to 100.0% while failures stand", () => {
// The real row that produced this: 12,698 attempts, 2 failures, 99.984%.
renderTable([
entry({
period: period({ attempts: 12_698, successes: 12_696, failures: 2, success_rate: 12_696 / 12_698 }),
}),
]);
expect(screen.queryByText("100.0%")).toBeNull();
expect(screen.getByText("99.9%")).toBeTruthy();
});
test("a rate a hair above nothing never rounds down to 0.0% while successes stand", () => {
renderTable([
entry({
period: period({ attempts: 12_698, successes: 2, failures: 12_696, success_rate: 2 / 12_698 }),
}),
]);
expect(screen.queryByText("0.0%")).toBeNull();
expect(screen.getByText("0.1%")).toBeTruthy();
});
test("a window with no failures at all still reads 100.0%", () => {
renderTable([entry({ period: period({ attempts: 500, successes: 500, failures: 0, success_rate: 1 }) })]);
expect(screen.getByText("100.0%")).toBeTruthy();
});
@@ -1,226 +0,0 @@
import * as stylex from "@stylexjs/stylex";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
},
heading: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
paddingInline: "1rem",
paddingTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
count: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
empty: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
note: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tableWrap: {
overflowX: "auto",
},
table: {
marginTop: "0.5rem",
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/**
* The two live columns are left outside the span: everything under it answers
* for the selected window, and nothing else on this card does.
*/
groupRow: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
groupHead: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "1rem",
paddingBottom: "0.25rem",
textAlign: "center",
fontWeight: 500,
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
textAlign: "left",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
th: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontWeight: 500,
},
thRight: {
textAlign: "right",
},
/** No hairline under the last row: the card border already closes the table. */
row: {
borderBottomWidth: { default: 1, ":last-child": 0 },
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
},
cellRight: {
textAlign: "right",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
bad: {
color: colors.danger,
},
});
/** Live pool state in one word. Configuration first: a disabled upstream is not backing off. */
function statusNow(upstream: UpstreamHealthEntry): "Available" | "Backing off" | "Disabled" {
if (!upstream.enabled) return "Disabled";
return upstream.available ? "Available" : "Backing off";
}
/**
* `success_rate` is null exactly when the window holds no attempt, and that must
* not read as perfect reliability hence the em-dash rather than `100.0%`.
*
* One decimal place cannot hold 12,696 of 12,698: it rounds to `100.0%`, and the
* row then claims perfection beside a failure count of 2. Neither endpoint may
* be reached by rounding only by actually having no failure, or no success.
*/
function successRate(period: UpstreamPeriodStats): string {
if (period.success_rate === null) return "—";
const rounded = period.success_rate * 100;
if (rounded > 99.9 && period.failures > 0) return "99.9%";
if (rounded < 0.1 && period.successes > 0) return "0.1%";
return `${rounded.toFixed(1)}%`;
}
/**
* The dashboard answers availability only. Failure detail what failed, when,
* and how often is the Diagnostics page's job, so `last_failure_at` and
* `last_failure_error` are read there rather than repeated in this row.
*/
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
const idle = health.upstreams.length > 0 && health.upstreams.every(({ period }) => period.attempts === 0);
return (
<section {...stylex.props(styles.card)}>
<h2 {...stylex.props(styles.heading)}>
Upstreams
<span {...stylex.props(styles.count, shared.tabularNums)}>
{health.available}/{health.total} available
</span>
</h2>
{health.upstreams.length === 0 ? (
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
) : (
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.groupRow)}>
<td colSpan={2} />
<th scope="colgroup" colSpan={3} {...stylex.props(styles.groupHead)}>
Selected period · {health.period}
</th>
</tr>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.th)}>
Upstream
</th>
<th scope="col" {...stylex.props(styles.th)}>
Status now
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Attempts
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Failures
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Success rate
</th>
</tr>
</thead>
<tbody>
{health.upstreams.map((upstream) => {
const status = statusNow(upstream);
return (
<tr key={upstream.url} {...stylex.props(styles.row)}>
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>
{upstream.url}
</td>
<td {...stylex.props(styles.cell)}>
<span
{...stylex.props(
status === "Backing off" && styles.bad,
status === "Disabled" && styles.muted,
)}
>
{status}
</span>
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.attempts)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.failures)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{successRate(upstream.period)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{idle && <p {...stylex.props(styles.note)}>No upstream attempts in this period.</p>}
{!health.complete && (
<p {...stylex.props(styles.note)}>
History incomplete: outcomes were dropped in this window, so these counts are a lower bound.
</p>
)}
</section>
);
}
@@ -1,93 +0,0 @@
import type { Bucket } from "@/lib/types";
import { MARGIN, isEmptyTimeseries, layoutTimeseries, niceTicks } from "./chartLayout";
function bucket(ts: number, queries: number, blocked = 0, cached = 0): Bucket {
return { ts, queries, blocked, cached };
}
describe("niceTicks", () => {
test("zero max yields a single zero tick", () => {
expect(niceTicks(0)).toEqual([0]);
});
test("picks a 1/2/5 step and extends past max", () => {
expect(niceTicks(7)).toEqual([0, 2, 4, 6, 8]);
expect(niceTicks(100)).toEqual([0, 50, 100]);
expect(niceTicks(1234)).toEqual([0, 500, 1000, 1500]);
});
});
describe("isEmptyTimeseries", () => {
test("true for no buckets and for all-zero buckets", () => {
expect(isEmptyTimeseries([])).toBe(true);
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 0)])).toBe(true);
});
test("false when any bucket has queries", () => {
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 3)])).toBe(false);
});
});
describe("layoutTimeseries", () => {
test("segment heights are proportional and stack to the queries total", () => {
const layout = layoutTimeseries([bucket(0, 100, 40, 10), bucket(60, 50, 0, 0)], 480, 240);
const plotHeight = 240 - MARGIN.top - MARGIN.bottom;
const baseline = MARGIN.top + plotHeight;
const [first, second] = layout.bars;
expect(layout.scaleMax).toBe(100);
expect(first.other).toBe(50);
expect(first.segments.blocked.height).toBeCloseTo(plotHeight * 0.4);
expect(first.segments.cached.height).toBeCloseTo(plotHeight * 0.1);
expect(first.segments.other.height).toBeCloseTo(plotHeight * 0.5);
expect(first.segments.blocked.y + first.segments.blocked.height).toBeCloseTo(baseline);
expect(first.segments.cached.y + first.segments.cached.height).toBeCloseTo(first.segments.blocked.y);
expect(first.segments.other.y + first.segments.other.height).toBeCloseTo(first.segments.cached.y);
expect(first.segments.other.y).toBeCloseTo(MARGIN.top);
expect(second.segments.other.height).toBeCloseTo(plotHeight * 0.5);
});
test("clamps other at zero when blocked + cached exceed queries", () => {
const layout = layoutTimeseries([bucket(0, 10, 8, 5)], 480, 240);
expect(layout.bars[0].other).toBe(0);
expect(layout.bars[0].segments.other.height).toBe(0);
});
test("zero data still lays out zero-height bars on a unit scale", () => {
const layout = layoutTimeseries([bucket(0, 0), bucket(60, 0)], 480, 240);
expect(layout.scaleMax).toBe(1);
expect(layout.bars).toHaveLength(2);
for (const bar of layout.bars) {
expect(bar.segments.blocked.height).toBe(0);
expect(bar.segments.cached.height).toBe(0);
expect(bar.segments.other.height).toBe(0);
}
expect(layout.yTicks).toEqual([{ value: 0, y: MARGIN.top + (240 - MARGIN.top - MARGIN.bottom) }]);
});
test("single bucket fills the plot width minus the gap", () => {
const layout = layoutTimeseries([bucket(0, 5, 1, 1)], 480, 240);
const plotWidth = 480 - MARGIN.left - MARGIN.right;
const bar = layout.bars[0];
expect(bar.slot.width).toBeCloseTo(plotWidth);
expect(bar.segments.blocked.width).toBeCloseTo(plotWidth - 2);
expect(bar.segments.blocked.x).toBeCloseTo(MARGIN.left + 1);
expect(layout.xTicks).toEqual([{ ts: 0, x: MARGIN.left + plotWidth / 2 }]);
});
test("x ticks thin out when buckets outnumber the label budget", () => {
const buckets = Array.from({ length: 168 }, (_, i) => bucket(i * 3600, i));
const layout = layoutTimeseries(buckets, 800, 240);
expect(layout.xTicks.length).toBeLessThan(buckets.length / 10);
expect(layout.xTicks[0].ts).toBe(0);
const xs = layout.xTicks.map((tick) => tick.x);
expect([...xs].sort((a, b) => a - b)).toEqual(xs);
});
test("empty bucket list yields no bars and no x ticks", () => {
const layout = layoutTimeseries([], 480, 240);
expect(layout.bars).toEqual([]);
expect(layout.xTicks).toEqual([]);
expect(layout.scaleMax).toBe(1);
});
});
-101
View File
@@ -1,101 +0,0 @@
import type { Bucket } from "@/lib/types";
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export interface BarLayout {
bucket: Bucket;
/** queries - blocked - cached, clamped at 0. */
other: number;
slot: Rect;
segments: {
blocked: Rect;
cached: Rect;
other: Rect;
};
}
export interface ChartLayout {
width: number;
height: number;
plot: Rect;
scaleMax: number;
bars: BarLayout[];
yTicks: { value: number; y: number }[];
xTicks: { ts: number; x: number }[];
}
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
const BAR_GAP = 2;
const MIN_X_LABEL_PX = 90;
/** Tick values from 0 upward in a 1/2/5 step, extended until the last tick covers `max`. */
export function niceTicks(max: number, targetCount = 4): number[] {
if (max <= 0) return [0];
const rawStep = max / targetCount;
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const normalized = rawStep / magnitude;
const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
const ticks: number[] = [];
for (let value = 0; ; value += step) {
ticks.push(value);
if (value >= max) break;
}
return ticks;
}
export function isEmptyTimeseries(buckets: Bucket[]): boolean {
return buckets.every((bucket) => bucket.queries === 0);
}
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
const plot: Rect = {
x: MARGIN.left,
y: MARGIN.top,
width: Math.max(0, width - MARGIN.left - MARGIN.right),
height: Math.max(0, height - MARGIN.top - MARGIN.bottom),
};
const maxQueries = buckets.reduce((max, bucket) => Math.max(max, bucket.queries), 0);
const tickValues = niceTicks(maxQueries);
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
const baseline = plot.y + plot.height;
const toHeight = (value: number) => (value / scaleMax) * plot.height;
const slotWidth = buckets.length > 0 ? plot.width / buckets.length : 0;
const barWidth = Math.max(1, slotWidth - BAR_GAP);
const bars: BarLayout[] = buckets.map((bucket, i) => {
const slotX = plot.x + i * slotWidth;
const barX = slotX + (slotWidth - barWidth) / 2;
const other = Math.max(0, bucket.queries - bucket.blocked - bucket.cached);
const blockedH = toHeight(bucket.blocked);
const cachedH = toHeight(bucket.cached);
const otherH = toHeight(other);
return {
bucket,
other,
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
segments: {
blocked: { x: barX, y: baseline - blockedH, width: barWidth, height: blockedH },
cached: { x: barX, y: baseline - blockedH - cachedH, width: barWidth, height: cachedH },
other: { x: barX, y: baseline - blockedH - cachedH - otherH, width: barWidth, height: otherH },
},
};
});
const yTicks = tickValues.map((value) => ({ value, y: baseline - toHeight(value) }));
const labelStep =
buckets.length > 0 && plot.width > 0
? Math.max(1, Math.ceil((buckets.length * MIN_X_LABEL_PX) / plot.width))
: 1;
const xTicks = bars
.filter((_, i) => i % labelStep === 0)
.map((bar) => ({ ts: bar.bucket.ts, x: bar.slot.x + bar.slot.width / 2 }));
return { width, height, plot, scaleMax, bars, yTicks, xTicks };
}
@@ -5,6 +5,7 @@ import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { DIAGNOSTIC_CODES, type DiagnosticEvent } from "@/lib/types";
import { health } from "@/lib/healthFixture";
import { EVENT_COPY } from "./eventCopy";
const NOW_S = Math.floor(Date.now() / 1000);
@@ -35,6 +36,8 @@ beforeEach(() => {
requested = [];
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
// The shell reads health for the Diagnostics nav badge on every route.
"/api/health": health(),
};
vi.stubGlobal(
"fetch",
@@ -97,7 +100,9 @@ test("an open episode shows its facts, its copy and the error the server sent",
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");
expect(screen.getByRole("link", { name: "Go to Blocklist sources" }).getAttribute("href")).toBe(
"/configuration/protection?tab=sources",
);
});
test("a resolved episode states how long it lasted, not how long it has run", async () => {
@@ -207,9 +207,27 @@ export default function DiagnosticDetailPage() {
{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>
{copy.link.to === "/configuration/protection" ? (
<Link
to="/configuration/protection"
search={{ tab: "sources" }}
{...stylex.props(styles.link, shared.focusRing)}
>
Go to {copy.link.label}
</Link>
) : copy.link.to === "/configuration/resolution" ? (
<Link
to="/configuration/resolution"
search={{ tab: "upstreams" }}
{...stylex.props(styles.link, shared.focusRing)}
>
Go to {copy.link.label}
</Link>
) : (
<Link to={copy.link.to} {...stylex.props(styles.link, shared.focusRing)}>
Go to {copy.link.label}
</Link>
)}
</p>
)}
@@ -4,6 +4,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { DiagnosticEvent, DiagnosticsPage } from "@/lib/types";
// Ages are rendered against the wall clock, so the fixtures are anchored to it
@@ -68,6 +69,9 @@ beforeEach(() => {
requested = [];
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
// The health strip at the top of the page; quiet on a healthy box, which is
// what every test below wants it to be.
"/api/health": health(),
"/api/diagnostics?state=active": ACTIVE,
"/api/diagnostics?state=resolved": RESOLVED,
};
@@ -256,7 +260,7 @@ test("only the resolved history offers a purge", async () => {
// An episode still failing is the state of the box, not history: no purge
// affordance anywhere on its card.
const active = screen.getByText("Blocklist source failed to update").closest("li")!;
const active = (await screen.findByText("Blocklist source failed to update")).closest("li")!;
expect(within(active).queryByRole("button", { name: "Purge" })).toBeNull();
const row = screen.getByText("Disk space low").closest("tr")!;
@@ -17,6 +17,7 @@ import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import HealthStrip from "./HealthStrip";
import SeverityBadge from "./SeverityBadge";
import { diagnosticsFilterOf } from "./filter";
import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy";
@@ -401,6 +402,8 @@ export default function DiagnosticsPage() {
repeats, and closes when the subject recovers.
</p>
<HealthStrip />
<RangeNotice since={search.since} until={search.until} />
<div {...stylex.props(styles.filterGrid)}>
@@ -0,0 +1,198 @@
/**
* The health strip on the Diagnostics page, through the real router.
*
* Its load contract is migrated whole from the deleted Overview status section:
* a visible loading state before the first reading, an error row with Retry when
* the first read fails, and a refetch failure that marks the conditions on
* screen as the last reading rather than the current state. What is new is that
* a condition explained on this page narrows this page instead of navigating.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { Health } from "@/lib/types";
let healthBody: Health;
let healthFails: boolean;
let requested: string[];
/** Held open to keep a health request in flight while a test looks at the strip. */
let pendingHealth: Promise<void> | null;
function json(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
beforeEach(() => {
healthBody = health();
healthFails = false;
requested = [];
pendingHealth = null;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
requested.push(url);
if (url === "/api/health") {
if (pendingHealth !== null) await pendingHealth;
return healthFails ? json({ error: "health unavailable" }, 400) : json(healthBody);
}
if (url === "/api/version")
return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 });
if (url.startsWith("/api/diagnostics"))
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
return json({ error: "not stubbed" }, 404);
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
function renderDiagnostics(path = "/diagnostics") {
const queryClient = createQueryClient();
const defaults = queryClient.getDefaultOptions();
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return { router, queryClient };
}
function strip(): HTMLElement {
return screen.getByRole("list", { name: "Current status" });
}
function fact(label: string): HTMLElement {
// First match, not only match: a condition's label and the link it offers can
// be the same word — Upstreams links to Upstreams — and the label comes first.
const cell = within(strip()).getAllByText(label)[0];
const item = cell.closest("li");
if (item === null) throw new Error(`no health fact for ${label}`);
return item;
}
test("the five conditions are stated in words, healthy ones without a way out", async () => {
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
for (const [label, value] of [
["Protection", "Active"],
["Upstreams", "Available"],
["Query history", "Recording"],
["Diagnostics", "Recording"],
["Storage", "OK"],
] as const) {
expect(within(fact(label)).getByText(value)).toBeTruthy();
}
expect(within(strip()).queryByRole("link")).toBeNull();
});
test("protection unavailable sends the reader to the sources, upstreams to the pool", async () => {
healthBody = health({
protection: { state: "unavailable", until: null },
upstreams: { state: "unavailable", available: 0, total: 2 },
});
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Protection")).getByRole("link", { name: "Blocklist sources" }).getAttribute("href")).toBe(
"/configuration/protection?tab=sources",
);
expect(within(fact("Upstreams")).getByRole("link", { name: "Upstreams" }).getAttribute("href")).toBe(
"/configuration/resolution?tab=upstreams",
);
});
test("a losing query log narrows this page to the disk, a failed writer to the query log", async () => {
healthBody = health({ query_history: { state: "losing", dropped_total: 4, last_drop_s: null } });
const { router } = renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Query history")).getByText("4 queries dropped")).toBeTruthy();
fireEvent.click(within(fact("Query history")).getByRole("link", { name: "Disk diagnostics" }));
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
await waitFor(() => expect(requested.some((url) => url.includes("component=disk"))).toBe(true));
});
test("a filter link drops a time window that would hide the episodes it points at", async () => {
healthBody = health({ disk: { state: "critical", free_bytes: 0 } });
const { router } = renderDiagnostics("/diagnostics?since=1000&until=2000&severity=error&state=resolved");
await waitFor(() => expect(strip()).toBeTruthy());
fireEvent.click(within(fact("Storage")).getByRole("link", { name: "Disk diagnostics" }));
// Everything that could hide the episode goes with the bounds: `state=resolved`
// would exclude the active disk episode this link exists to show, and an
// `error` severity would exclude it whenever it is a warning.
await waitFor(() => expect(router.state.location.search).toEqual({ component: "disk" }));
});
test("an unavailable diagnostics store explains itself and offers no link into itself", async () => {
healthBody = health({ diagnostics: { state: "unavailable", active_warnings: 0, active_errors: 0 } });
renderDiagnostics();
await waitFor(() => expect(strip()).toBeTruthy());
const row = fact("Diagnostics");
expect(within(row).getByText(/not being recorded/)).toBeTruthy();
expect(within(row).queryByRole("link")).toBeNull();
});
test("the strip says it is loading before the first reading, never empty conditions", async () => {
// Through the route, which is the path that matters: the loader starts the
// health request without waiting for it, so the page paints while the reading
// is still in flight and the strip has to say so.
let release = () => {};
pendingHealth = new Promise<void>((resolve) => {
release = resolve;
});
renderDiagnostics();
expect(await screen.findByText("Loading status…")).toBeTruthy();
// Scoped to the page: "Protection" is also a nav destination now.
expect(within(document.querySelector("main") as HTMLElement).queryByText("Protection")).toBeNull();
release();
await waitFor(() => expect(strip()).toBeTruthy());
expect(screen.queryByText("Loading status…")).toBeNull();
});
test("a failed first health read is an error row with Retry, not a healthy strip", async () => {
healthFails = true;
renderDiagnostics();
await screen.findByText("health unavailable");
expect(screen.queryByRole("list", { name: "Current status" })).toBeNull();
});
test("a reading that has gone stale says so rather than passing for current", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderDiagnostics();
await vi.waitFor(() => expect(strip()).toBeTruthy());
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
// The next poll fails. The conditions on screen are the last that arrived and
// must not keep passing for the current state.
healthFails = true;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.getByText(/last reading that arrived/)).toBeTruthy());
expect(within(fact("Storage")).getByText("OK")).toBeTruthy();
// Recovery clears the caption rather than leaving the page permanently unsure.
healthFails = false;
await vi.advanceTimersByTimeAsync(11_000);
await vi.waitFor(() => expect(screen.queryByText(/last reading that arrived/)).toBeNull());
});
@@ -0,0 +1,195 @@
/**
* The five health conditions, compactly, at the top of the page that explains
* failures. Healthy conditions stay quiet; a degraded one is highlighted and
* offers the way out.
*
* A degraded condition whose explanation is on this page narrows this page
* rather than navigating away: the filter link sets `component` and drops every
* other filter. A time window, a severity or a `state=resolved` left from an
* earlier investigation would each hide the very episode the reader was sent to
* read, and a link that lands on "no events" states something false.
*
* The load contract is the one the deleted Overview status section carried: a
* visible loading state before the first reading, and a refetch failure that
* says so the conditions on screen become the last reading that arrived, never
* a claim about the current state, until a poll succeeds again.
*/
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { healthQuery } from "@/lib/queries";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { healthFacts, type FactLink, type FactTone, type HealthFact } from "./healthFacts";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
list: {
marginTop: "0.75rem",
display: "grid",
gap: "0.5rem",
// Five is prime, so every count between one and five leaves a short last
// row; three columns made it 3 then 2, which reads as a layout that ran out
// of room rather than one that chose. So the strip goes from one column
// straight to two and then to a single row of five, and is never ragged.
gridTemplateColumns: {
default: "minmax(0, 1fr)",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1100px)": "repeat(5, minmax(0, 1fr))",
},
listStyleType: "none",
padding: 0,
},
fact: {
display: "flex",
flexWrap: "wrap",
alignItems: "baseline",
gap: "0.375rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "0.625rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** Quiet: a healthy condition is the normal state and gets no emphasis. */
quiet: {
borderColor: colors.border,
backgroundColor: "transparent",
},
highlighted: {
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
},
label: {
color: colors.textSecondary,
},
value: {
fontWeight: 500,
},
detail: {
flexBasis: "100%",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
link: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
ok: {
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
notice: {
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
warn: {
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
danger: {
color: colors.dangerText,
},
message: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
/** Text and icon carry the state; the colour only agrees with them. */
const ICONS: Record<FactTone, string> = { ok: "●", notice: "‖", warn: "!", danger: "✕" };
function FactLinkAnchor({ link }: { link: FactLink }) {
if (link.kind === "filter") {
return (
<Link
to="/diagnostics"
// Sets the component and clears every filter that could hide what it
// points at: a time window from an older investigation, and a severity
// or state — `resolved` above all — that would exclude the very episode
// explaining the condition this link came from.
search={() => ({ component: link.component })}
{...stylex.props(styles.link, shared.focusRing)}
>
{link.label}
</Link>
);
}
// The tab belongs to the destination, not to the fact: a source that stopped
// loading is read on Protection's Sources tab, an upstream on Resolution's.
if (link.to === "/configuration/protection") {
return (
<Link
to="/configuration/protection"
search={{ tab: "sources" }}
{...stylex.props(styles.link, shared.focusRing)}
>
{link.label}
</Link>
);
}
return (
<Link
to="/configuration/resolution"
search={{ tab: "upstreams" }}
{...stylex.props(styles.link, shared.focusRing)}
>
{link.label}
</Link>
);
}
function Fact({ fact }: { fact: HealthFact }) {
return (
<li {...stylex.props(styles.fact, fact.tone === "ok" ? styles.quiet : styles.highlighted)}>
<span aria-hidden="true" {...stylex.props(TONES[fact.tone])}>
{ICONS[fact.tone]}
</span>
<span {...stylex.props(styles.label)}>{fact.label}</span>
<span {...stylex.props(styles.value, TONES[fact.tone])}>{fact.value}</span>
{fact.link !== undefined && <FactLinkAnchor link={fact.link} />}
{fact.detail !== undefined && <span {...stylex.props(styles.detail)}>{fact.detail}</span>}
</li>
);
}
export default function HealthStrip() {
const health = useQuery(healthQuery());
if (health.data === undefined) {
return health.isError ? (
<InlineError error={health.error} onRetry={() => void health.refetch()} />
) : (
<p role="status" {...stylex.props(styles.message, shared.pulse)}>
Loading status
</p>
);
}
return (
<>
<ul aria-label="Current status" {...stylex.props(styles.list)}>
{healthFacts(health.data).map((fact) => (
<Fact key={fact.key} fact={fact} />
))}
</ul>
{health.isError && (
<>
<p role="status" {...stylex.props(styles.message)}>
This is the last reading that arrived. The current state is unknown.
</p>
<InlineError error={health.error} onRetry={() => void health.refetch()} />
</>
)}
</>
);
}
@@ -48,3 +48,20 @@ test("component labels read as prose without inventing a name", () => {
expect(componentLabel("query_log")).toBe("Query log");
expect(componentLabel("disk")).toBe("Disk");
});
test("the legacy upstream_history code keeps its copy, so a retained episode still reads as prose", () => {
// Nothing emits it any more, but stored rows outlive the subsystem and the
// list endpoint passes their codes through verbatim.
expect(DIAGNOSTIC_CODES).toContain("upstream_history.write");
const copy = EVENT_COPY["upstream_history.write"];
expect(copy.title).toBe("Upstream history write failed");
expect(copy.impact).toMatch(/no longer runs/);
});
test("the recreated-log copy covers a planned schema change as well as an unreadable file", () => {
const copy = EVENT_COPY["query_log.recreated"];
// The planned cause leads, because it is the one an upgrade produces; the
// unreadable file is the other cause and must not be dropped from the copy.
expect(copy.impact).toMatch(/schema/);
expect(copy.impact).toMatch(/could not be read/);
});
+17 -9
View File
@@ -8,14 +8,16 @@
* `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.
* `link` points at the configuration surface that governs the failure. The
* path alone decides which tab of that surface the detail page opens, so the
* copy never carries a tab of its own to drift from it.
*/
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 type CopyLinkPath =
"/configuration/system" | "/configuration/protection" | "/configuration/resolution" | "/clients";
export interface EventCopy {
title: string;
@@ -39,9 +41,9 @@ export function copyFor(code: DiagnosticCode): EventCopy {
);
}
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 SETTINGS = { to: "/configuration/system", label: "System" } as const;
const BLOCKLISTS = { to: "/configuration/protection", label: "Blocklist sources" } as const;
const UPSTREAMS = { to: "/configuration/resolution", label: "Upstreams" } as const;
const CLIENTS = { to: "/clients", label: "Clients" } as const;
/**
@@ -111,14 +113,20 @@ export const EVENT_COPY: Record<DiagnosticCode, EventCopy> = {
},
"query_log.recreated": {
title: "Query log recreated",
impact: "The old log database was unreadable and was moved aside; the history it held is not in the new one.",
impact: "The old log database was moved aside — a release changed its schema, or the file could not be read — and the history it held is not in the new one.",
remediation: "Keep or delete the aside file named below. Nothing else is required — logging is running.",
link: SETTINGS,
},
/**
* Legacy. Nothing emits this code any more: milestone 30 deleted the
* upstream-minute history subsystem. Stored rows outlive it, and the list
* endpoint passes their codes through verbatim, so the copy stays a
* retained episode must still read as prose rather than as a dotted string.
*/
"upstream_history.write": {
title: "Upstream history write failed",
impact: "Resolution is unaffected; the per-upstream success and failure aggregates lose the affected window.",
remediation: "Check free disk space and the configuration database's permissions.",
impact: "A recorded failure of a subsystem this version no longer runs. Resolution was unaffected; the per-upstream aggregates it fed are gone.",
remediation: "Nothing to do. Purge the entry once you have read it.",
link: UPSTREAMS,
},
"upstream.exchange": {
@@ -0,0 +1,97 @@
/**
* The health-fact matrix, migrated whole from the Overview status rows this
* replaces. Same states, same words, same link matrix with the Diagnostics
* links now narrowing the page the strip sits on rather than navigating to it.
*/
import { health } from "@/lib/healthFixture";
import { healthFacts, type HealthFact } from "./healthFacts";
const LOCALE = "en-GB";
const TZ = "UTC";
function factsBy(overrides: Parameters<typeof health>[0] = {}): Record<string, HealthFact> {
return Object.fromEntries(healthFacts(health(overrides), LOCALE, TZ).map((fact) => [fact.key, fact]));
}
test("a healthy box is five quiet facts, none of them linking anywhere", () => {
const facts = healthFacts(health(), LOCALE, TZ);
expect(facts.map((fact) => fact.key)).toEqual(["protection", "upstreams", "query_history", "diagnostics", "disk"]);
expect(facts.every((fact) => fact.tone === "ok")).toBe(true);
expect(facts.every((fact) => fact.link === undefined)).toBe(true);
});
test("protection: active, paused indefinitely, paused until a time, unavailable", () => {
expect(factsBy()["protection"].value).toBe("Active");
expect(factsBy({ protection: { state: "paused", until: null } })["protection"].value).toBe("Paused");
const timed = factsBy({ protection: { state: "paused", until: Date.UTC(2026, 0, 1, 14, 5) / 1000 } })["protection"];
expect(timed.value).toBe("Paused until 14:05");
const gone = factsBy({ protection: { state: "unavailable", until: null } })["protection"];
expect(gone.value).toBe("Unavailable");
expect(gone.link).toEqual({ kind: "route", to: "/configuration/protection", label: "Blocklist sources" });
});
test("a pause never reads as a fault, and never carries a way out", () => {
const paused = factsBy({ protection: { state: "paused", until: null } })["protection"];
expect(paused.tone).toBe("notice");
expect(paused.link).toBeUndefined();
});
test("upstreams count the enabled pool, and only an empty one links out", () => {
const ok = factsBy({ upstreams: { state: "ok", available: 1, total: 3 } })["upstreams"];
expect(ok.value).toBe("Available");
expect(ok.detail).toBe("1 of 3 enabled");
expect(ok.link).toBeUndefined();
const none = factsBy({ upstreams: { state: "unavailable", available: 0, total: 3 } })["upstreams"];
expect(none.value).toBe("None reachable");
expect(none.link).toEqual({ kind: "route", to: "/configuration/resolution", label: "Upstreams" });
});
test("query history: losing blames the disk gate, a failed writer blames the query log", () => {
const losing = factsBy({ query_history: { state: "losing", dropped_total: 0, last_drop_s: null } })[
"query_history"
];
expect(losing.value).toBe("Losing rows");
expect(losing.link).toEqual({ kind: "filter", component: "disk", label: "Disk diagnostics" });
const failed = factsBy({ query_history: { state: "failed", dropped_total: 0, last_drop_s: null } })[
"query_history"
];
expect(failed.value).toBe("Writer failed");
expect(failed.link).toEqual({ kind: "filter", component: "query_log", label: "Query log diagnostics" });
});
test("drops are reported while recording, with or without a stamp on the last one", () => {
const stamped = factsBy({
query_history: { state: "recording", dropped_total: 5, last_drop_s: Date.UTC(2026, 0, 1, 9, 30) / 1000 },
})["query_history"];
expect(stamped.tone).toBe("ok");
expect(stamped.detail).toBe("5 queries dropped, last at 09:30");
const unstamped = factsBy({ query_history: { state: "recording", dropped_total: 1, last_drop_s: null } })[
"query_history"
];
expect(unstamped.detail).toBe("1 query dropped");
expect(factsBy()["query_history"].detail).toBeUndefined();
});
test("an unavailable diagnostics store explains itself and links nowhere", () => {
const fact = factsBy({ diagnostics: { state: "unavailable", active_warnings: 0, active_errors: 0 } })[
"diagnostics"
];
expect(fact.value).toBe("Unavailable");
expect(fact.detail).toContain("not being recorded");
// The page a link would filter is the thing that is broken.
expect(fact.link).toBeUndefined();
});
test("storage names the three disk states and always shows what is free", () => {
expect(factsBy()["disk"].value).toBe("OK");
expect(factsBy()["disk"].link).toBeUndefined();
const low = factsBy({ disk: { state: "low", free_bytes: 1024 } })["disk"];
expect(low.value).toBe("Low");
expect(low.tone).toBe("warn");
expect(low.link).toEqual({ kind: "filter", component: "disk", label: "Disk diagnostics" });
const critical = factsBy({ disk: { state: "critical", free_bytes: 0 } })["disk"];
expect(critical.value).toBe("Critical");
expect(critical.tone).toBe("danger");
expect(critical.detail).toBe("0 B free");
});
@@ -0,0 +1,133 @@
/**
* The five conditions `GET /api/health` reports, as facts.
*
* A pure projection of `Health` so the whole matrix every state of every
* condition, and every way out a degraded one offers is testable without a
* router or a fetch. `HealthStrip` only paints what this returns.
*
* A healthy fact is quiet: no badge, no panel, no green reassurance, so the one
* condition that is not healthy is the thing the eye lands on.
*/
import { formatBytes, formatClock } from "@/lib/format";
import type { Health } from "@/lib/types";
export type FactTone = "ok" | "notice" | "warn" | "danger";
/**
* Where a degraded fact sends the reader. A `filter` link stays on this page and
* narrows it to the component that failed; a `route` link leaves for the surface
* that can fix the condition.
*/
export type FactLink =
| { kind: "route"; to: "/configuration/protection" | "/configuration/resolution"; label: string }
| { kind: "filter"; component: string; label: string };
export interface HealthFact {
key: "protection" | "upstreams" | "query_history" | "diagnostics" | "disk";
label: string;
tone: FactTone;
/** The state, in the reader's words. Never colour alone. */
value: string;
detail?: string;
link?: FactLink;
}
const numberFormat = new Intl.NumberFormat();
/** Kept out of the state rule: rows are lost whether or not the box is losing them now. */
function dropText(dropped: number, lastDrop: number | null, locale?: string, timeZone?: string): string | undefined {
if (dropped <= 0) return undefined;
const count = `${numberFormat.format(dropped)} ${dropped === 1 ? "query" : "queries"} dropped`;
return lastDrop === null ? count : `${count}, last at ${formatClock(lastDrop, locale, timeZone)}`;
}
function protectionFact(protection: Health["protection"], locale?: string, timeZone?: string): HealthFact {
if (protection.state === "unavailable") {
return {
key: "protection",
label: "Protection",
tone: "danger",
value: "Unavailable",
detail: "No filter snapshot is published, so queries are not being filtered.",
link: { kind: "route", to: "/configuration/protection", label: "Blocklist sources" },
};
}
if (protection.state === "paused") {
return {
key: "protection",
label: "Protection",
tone: "notice",
value:
protection.until === null
? "Paused"
: `Paused until ${formatClock(protection.until, locale, timeZone)}`,
};
}
return { key: "protection", label: "Protection", tone: "ok", value: "Active" };
}
function queryHistoryFact(history: Health["query_history"], locale?: string, timeZone?: string): HealthFact {
const detail = dropText(history.dropped_total, history.last_drop_s, locale, timeZone);
if (history.state === "failed") {
return {
key: "query_history",
label: "Query history",
tone: "danger",
value: "Writer failed",
detail,
link: { kind: "filter", component: "query_log", label: "Query log diagnostics" },
};
}
if (history.state === "losing") {
// The disk gate is what is holding the writes back, and it may be the only
// thing that has reported: query_log itself need not have an episode open.
return {
key: "query_history",
label: "Query history",
tone: "danger",
value: "Losing rows",
detail,
link: { kind: "filter", component: "disk", label: "Disk diagnostics" },
};
}
return { key: "query_history", label: "Query history", tone: "ok", value: "Recording", detail };
}
export function healthFacts(health: Health, locale?: string, timeZone?: string): HealthFact[] {
const { upstreams, diagnostics, disk } = health;
return [
protectionFact(health.protection, locale, timeZone),
{
key: "upstreams",
label: "Upstreams",
tone: upstreams.state === "unavailable" ? "danger" : "ok",
value: upstreams.state === "unavailable" ? "None reachable" : "Available",
detail: `${upstreams.available} of ${upstreams.total} enabled`,
...(upstreams.state === "unavailable"
? { link: { kind: "route", to: "/configuration/resolution", label: "Upstreams" } as FactLink }
: {}),
},
queryHistoryFact(health.query_history, locale, timeZone),
diagnostics.state === "unavailable"
? {
key: "diagnostics",
label: "Diagnostics",
tone: "danger",
value: "Unavailable",
// No link: the page it would filter is the thing that is broken.
detail: "Diagnostics are not being recorded. Check free disk space and the configuration database's permissions.",
}
: { key: "diagnostics", label: "Diagnostics", tone: "ok", value: "Recording" },
{
key: "disk",
label: "Storage",
tone: disk.state === "critical" ? "danger" : disk.state === "low" ? "warn" : "ok",
value: disk.state === "critical" ? "Critical" : disk.state === "low" ? "Low" : "OK",
detail: `${formatBytes(disk.free_bytes)} free`,
...(disk.state === "ok"
? {}
: { link: { kind: "filter", component: "disk", label: "Disk diagnostics" } as FactLink }),
},
];
}
@@ -1,135 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import type { Mock } from "vitest";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
const BLOCKLISTS = {
blocklists: [
{
id: 1,
url: "https://example.com/ads.txt",
name: "Ads",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 100,
wildcard_count: 0,
skipped_regex_count: 0,
checksum: null,
},
{
id: 2,
url: "https://example.com/malware.txt",
name: "Malware",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 50,
wildcard_count: 0,
skipped_regex_count: 0,
checksum: null,
},
],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
const BASE = {
"GET /api/groups": GROUPS,
"GET /api/blocklists": BLOCKLISTS,
"GET /api/version": VERSION,
"GET /api/groups/2/sources": { source_ids: [1] },
"PUT /api/groups/2/sources": { source_ids: [1, 2] },
};
function stubFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
}
async function renderGroupsPage(map: Record<string, unknown>) {
stubFetch(map);
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/groups"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: "Groups" });
}
function groupRow(name: string): HTMLElement {
const row = screen.getByText(name).closest("li");
if (row === null) throw new Error(`no row for group ${name}`);
return row;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("lists groups; the default group blocks rename and delete client-side", async () => {
await renderGroupsPage(BASE);
const defaultRow = groupRow("default");
expect((within(defaultRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(defaultRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect(within(defaultRow).getByText("The default group cannot be renamed or deleted.")).toBeTruthy();
const kidsRow = groupRow("kids");
expect((within(kidsRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(kidsRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(kidsRow).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
});
test("expanding sources loads the set, toggling saves the full set via PUT", async () => {
await renderGroupsPage(BASE);
const kidsRow = groupRow("kids");
fireEvent.click(within(kidsRow).getByRole("button", { name: "Sources" }));
const ads = (await within(kidsRow).findByRole("checkbox", { name: "Ads" })) as HTMLInputElement;
const malware = within(kidsRow).getByRole("checkbox", { name: "Malware" }) as HTMLInputElement;
expect(ads.checked).toBe(true);
expect(malware.checked).toBe(false);
const save = within(kidsRow).getByRole("button", { name: "Save sources" }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(malware);
expect(save.disabled).toBe(false);
fireEvent.click(save);
await waitFor(() => expect(save.disabled).toBe(true));
const calls = (fetch as unknown as Mock).mock.calls as [RequestInfo | URL, RequestInit | undefined][];
const put = calls.find(([, init]) => init?.method === "PUT");
expect(put).toBeTruthy();
expect(String(put![0])).toBe("/api/groups/2/sources");
expect(JSON.parse(String(put![1]?.body))).toEqual({ source_ids: [1, 2] });
expect(malware.checked).toBe(true);
});
-289
View File
@@ -1,289 +0,0 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
blocklistsQuery,
groupCreateMutation,
groupDeleteMutation,
groupsQuery,
groupUpdateMutation,
} from "@/lib/queries";
import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
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 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());
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const queryClient = useQueryClient();
const createMutation = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
const readOnly = useReadOnlyConfig();
return (
<section>
<h1 {...stylex.props(styles.heading)}>Groups</h1>
<form
{...stylex.props(styles.createForm)}
onSubmit={(event) => {
event.preventDefault();
const name = newName.trim();
if (name === "") return;
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
}}
>
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
New group
</label>
<input
id="new-group-name"
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
disabled={readOnly}
{...stylex.props(shared.smallInput, shared.focusRing)}
/>
<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 {...stylex.props(styles.list)}>
{groups.map((group) => (
<GroupRow key={group.id} group={group} blocklists={blocklists} />
))}
</ul>
</section>
);
}
function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[] }) {
const queryClient = useQueryClient();
const updateMutation = useMutation(groupUpdateMutation(queryClient));
const deleteMutation = useMutation(groupDeleteMutation(queryClient));
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(group.name);
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 {...stylex.props(styles.row)}>
<div {...stylex.props(styles.rowControls)}>
{renaming ? (
<form
{...stylex.props(styles.renameForm)}
onSubmit={(event) => {
event.preventDefault();
const trimmed = name.trim();
if (trimmed === "") return;
updateMutation.mutate(
{ id: group.id, input: { name: trimmed, safe_search: group.safe_search } },
{ onSuccess: () => setRenaming(false) },
);
}}
>
<input
type="text"
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
{...stylex.props(shared.smallInput, shared.focusRing)}
autoFocus
/>
<button
type="submit"
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Save
</button>
<button
type="button"
onClick={() => {
setName(group.name);
setRenaming(false);
}}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel
</button>
</form>
) : (
<span {...stylex.props(styles.name)}>{group.name}</span>
)}
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={group.safe_search}
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.focusRing)}
onChange={(event) =>
updateMutation.mutate({
id: group.id,
input: { name: group.name, safe_search: event.target.checked },
})
}
/>
Safe search
</label>
<span {...stylex.props(styles.actions)}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Sources
</button>
{!renaming && (
<button
type="button"
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => {
setName(group.name);
setRenaming(true);
}}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Rename
</button>
)}
{confirming ? (
<>
<button
type="button"
onClick={() => {
setConfirming(false);
deleteMutation.mutate(group.id);
}}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirming(false)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel
</button>
</>
) : (
<button
type="button"
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => setConfirming(true)}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
>
Delete
</button>
)}
</span>
</div>
{isDefault && <p {...stylex.props(styles.lockNote)}>{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={updateMutation.error ?? deleteMutation.error} />
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
</li>
);
}
@@ -1,22 +0,0 @@
import { sameSet, toggleSource } from "./sourceSet";
test("toggleSource adds a missing id keeping ascending order", () => {
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
expect(toggleSource([], 5)).toEqual([5]);
});
test("toggleSource removes a present id", () => {
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
expect(toggleSource([5], 5)).toEqual([]);
});
test("toggleSource twice is a no-op set-wise", () => {
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
});
test("sameSet compares regardless of order", () => {
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
expect(sameSet([], [])).toBe(true);
expect(sameSet([1, 2], [1, 2, 3])).toBe(false);
expect(sameSet([1, 2], [1, 4])).toBe(false);
});
@@ -1,170 +0,0 @@
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
@@ -1,27 +0,0 @@
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>
);
}
@@ -0,0 +1,277 @@
import { fireEvent, render as renderBare, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import { formatTime } from "@/lib/format";
import ClientChart, { type ClientChartData } from "./ClientChart";
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
const SINCE = 1_700_000_000;
const BUCKET = 1800;
function clients(named: { client: string; buckets: number[] }[], other: number[]): ClientChartData {
return { since: SINCE, bucket_seconds: BUCKET, clients: named, other };
}
const TWO_BUCKETS = clients(
[
{ client: "192.0.2.30", buckets: [10, 1] },
{ client: "192.0.2.31", buckets: [20, 1] },
],
[5, 1],
);
/**
* The chart looks a client's registered name up, so it needs a query client. No
* client is registered in these fixtures, which is what leaves the addresses on
* screen as the labels.
*/
function render(data: ClientChartData) {
const client = createQueryClient();
const tree = (next: ClientChartData) => (
<QueryClientProvider client={client}>
<ClientChart data={next} />
</QueryClientProvider>
);
const result = renderBare(tree(data));
return { ...result, rerender: (next: ClientChartData) => result.rerender(tree(next)) };
}
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Promise.resolve(
new Response(JSON.stringify({ clients: [] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
),
);
});
afterEach(() => vi.unstubAllGlobals());
function overlayRects(container: HTMLElement): SVGRectElement[] {
return Array.from(container.querySelectorAll<SVGRectElement>('rect[fill="transparent"]'));
}
test("the data table is the SVG's accessible equivalent", () => {
render(TWO_BUCKETS);
expect(screen.getByRole("img", { name: /client activity over time/i })).toBeTruthy();
const table = screen.getByRole("table", { name: "Queries per client per time bucket" });
expect(within(table).getAllByRole("row").length).toBe(3);
});
/**
* The x-axis is this response's own `since` plus one bucket width per column.
* The timeseries endpoint aligns its buckets with these, which is what lets the
* two charts stack above one another without either knowing about the other.
*/
test("the columns are timestamped from this response's own window", () => {
render(TWO_BUCKETS);
const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
expect(rows.map((row) => row.textContent)).toEqual([formatTime(SINCE), formatTime(SINCE + BUCKET)]);
});
/**
* Every series here is a disjoint part of the whole, so the scale has to come
* from the tallest column's own sum. Taking it from the largest single value
* instead would run the tallest column off the top of the plot: the axis has to
* reach 35 here, not 20.
*/
test("the value scale covers the tallest column's total, not its largest series", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [20] }], [15]));
const labels = Array.from(container.querySelectorAll(".visx-axis-left text")).map((label) => label.textContent);
expect(labels[labels.length - 1]).toBe("35");
});
test("the series are drawn in the colour of the client's address, and Other in its own", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [5]));
const fills = Array.from(container.querySelectorAll("rect"))
.map((rect) => rect.getAttribute("fill"))
.filter((fill) => fill !== "transparent");
expect(fills).toEqual([seriesColor(clientKey("192.0.2.30")), seriesColor(OTHER_KEY)]);
});
test("a window with no queries says so instead of drawing an empty grid", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [0, 0] }], [0, 0]));
expect(screen.getByText("No queries in this period.")).toBeTruthy();
expect(container.querySelector("svg")).toBeNull();
});
test("no bucket at all says the same thing", () => {
render(clients([], []));
expect(screen.getByText("No queries in this period.")).toBeTruthy();
});
test("hover text is the tooltip alone, never a bare SVG title", () => {
const { container } = render(TWO_BUCKETS);
expect(container.querySelectorAll("title")).toHaveLength(0);
});
test("each bucket's hit target spans the full plot height", () => {
const { container } = render(TWO_BUCKETS);
const rects = overlayRects(container);
expect(rects).toHaveLength(2);
for (const rect of rects) {
expect(rect.getAttribute("y")).toBe("8");
expect(rect.getAttribute("height")).toBe("210");
}
});
/**
* A band scale spends a gap after the last column as well as between them, so a
* full-step hit target on the last bucket would reach into the right margin.
*/
test("the last hit target stops at the plot's right edge", () => {
const { container } = render(TWO_BUCKETS);
const last = overlayRects(container).at(-1) as SVGRectElement;
const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
// 640 fallback width, less the 44px left and 8px right margins.
expect(right).toBeCloseTo(44 + 588, 6);
});
/**
* The window refreshes every half minute under an open tooltip. The tooltip
* holds a bucket index and reads the numbers out of the render it is drawing, so
* a refresh that keeps the same buckets updates it rather than leaving last
* minute's counts on screen.
*/
test("a refresh in the same window retells the hovered bucket with the new counts", () => {
const { container, rerender } = render(TWO_BUCKETS);
fireEvent.mouseOver(overlayRects(container)[0]);
expect(
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
).toEqual(["35", "10", "20", "5"]);
rerender(
clients(
[
{ client: "192.0.2.30", buckets: [11, 1] },
{ client: "192.0.2.31", buckets: [22, 1] },
],
[6, 1],
),
);
expect(
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
).toEqual(["39", "11", "22", "6"]);
});
/**
* A rolling window is the case a stored copy gets wrong: the bucket the pointer
* was over is gone, so index 0 now names a different span.
*/
test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
const { container, rerender } = render(TWO_BUCKETS);
fireEvent.mouseOver(overlayRects(container)[0]);
expect(container.querySelectorAll("dl")).toHaveLength(1);
rerender({ ...TWO_BUCKETS, since: SINCE + BUCKET });
expect(container.querySelectorAll("dl")).toHaveLength(0);
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
});
/** The placement the hand-rolled tooltip had, restored over visx's 10px defaults. */
test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
const { container } = render(TWO_BUCKETS);
fireEvent.mouseOver(overlayRects(container)[0]);
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
expect(tooltip.style.transform).toBe("translate(200px, 8px)");
});
/**
* `withBoundingRects` measures its node once, on mount, so the buckets must not
* share one. jsdom reports every rect as zero, so the mount is what this can
* observe, not the measurement itself.
*/
test("each bucket gets its own tooltip mount, so each is measured for itself", () => {
const { container } = render(TWO_BUCKETS);
const rects = overlayRects(container);
fireEvent.mouseOver(rects[0]);
const first = container.querySelector(".visx-tooltip");
fireEvent.mouseOver(rects[1]);
expect(first).not.toBeNull();
expect(container.querySelector(".visx-tooltip")).not.toBe(first);
});
test("pointing at a bucket names its total and every series, and dims the rest", () => {
const { container } = render(TWO_BUCKETS);
fireEvent.mouseOver(overlayRects(container)[0]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
expect(Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent)).toEqual([
"Queries",
"192.0.2.30",
"192.0.2.31",
"Other",
]);
expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["35", "10", "20", "5"]);
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
expect(swatches[0]).toContain(seriesColor(clientKey("192.0.2.30")));
expect(swatches[2]).toContain(seriesColor(OTHER_KEY));
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
});
test("leaving the chart takes the tooltip and the dimming with it", () => {
const { container } = render(TWO_BUCKETS);
fireEvent.mouseOver(overlayRects(container)[0]);
expect(container.querySelectorAll("dl")).toHaveLength(1);
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
expect(container.querySelectorAll("dl")).toHaveLength(0);
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
});
/**
* "Other" is everything outside the top eight clients. In a window where it
* counted nothing there is no eighth client to aggregate, and the entry would
* appear in the legend, the stack, the tooltip and the table saying only that it
* is empty. The named clients stay at zero: a client that went quiet is a fact.
*/
test("a window where Other counted nothing drops it from every surface", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 0]));
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual(["192.0.2.30"]);
expect(
within(screen.getByRole("table"))
.getAllByRole("columnheader")
.map((cell) => cell.textContent),
).toEqual(["Time", "192.0.2.30"]);
fireEvent.mouseOver(overlayRects(container)[0]);
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "192.0.2.30"]);
});
test("one query outside the named clients is enough to keep Other", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 1]));
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
"192.0.2.30",
"Other",
]);
});

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