Compare commits
28
Commits
49c7da2381
...
v0.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8fd9fee48
|
||
|
|
f1a85d3dab
|
||
|
|
72dbcbe24f
|
||
|
|
c5875af8c8
|
||
|
|
409384ee9e
|
||
|
|
9eb78f6171
|
||
|
|
cc23c97218
|
||
|
|
5da4652e89
|
||
|
|
7e0df5fd94
|
||
|
|
025edbb093
|
||
|
|
648d9b4496
|
||
|
|
17422fac21
|
||
|
|
fa323c7ed4
|
||
|
|
0fd6bbd312
|
||
|
|
7e6cb507d2
|
||
|
|
8a17e9ed21
|
||
|
|
0107df5f99
|
||
|
|
b6cea3f539
|
||
|
|
9b0b7c19f4
|
||
|
|
324704b53f
|
||
|
|
addf24f92c
|
||
|
|
037f209179
|
||
|
|
3dd8214ef2
|
||
|
|
64c0d723a6
|
||
|
|
377f00a35f
|
||
|
|
e0a7cd8a6b
|
||
|
|
3ed9a57822
|
||
|
|
0601098ab0
|
@@ -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.
|
|
||||||
@@ -101,6 +101,11 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -qq -y --no-install-recommends qemu-user
|
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)
|
- name: Run test suite under qemu (plain suite, no -Dintegration)
|
||||||
run: zig build test-aarch64 -fqemu
|
run: zig build test-aarch64 -fqemu
|
||||||
|
|
||||||
|
|||||||
+88
-1
@@ -4,7 +4,94 @@ 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.
|
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.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.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Every logged query has a detail page.** A row in Activity now links to `/activity/queries/{id}`, which explains that one query in the order it was decided: the request, the group it was matched under, the policy verdict with the rule that produced it and the blocklist source that rule came from, any CNAME uncloaking or safe-search rewrite, the route the answer took — blocked, local, forward zone, upstream or cache — and what the client got back, RCODE and duration included. `GET /api/queries/{id}` serves the same object; an id that retention has already deleted is a 404. The live view carries the same provenance for the queries it streams, so a query is explainable as it happens as well as afterwards.
|
||||||
|
- **Query Log, Live and Lookup are one Activity page.** `/activity` is the single surface for what nxdns answered: History reads the stored log, Live reads the stream, and both show the same seven columns — Time, Domain, Client, Type, Result, Route, Duration. The mode and every filter live in the URL, so an investigation is one link that shows the recipient exactly what you were looking at, and an absolute time range stays that range instead of drifting as the day goes on. A new **Result** column says what the client actually got — `Blocked`, `NOERROR`, `SERVFAIL` and the rest — with the **Route** column beside it saying how the answer was produced, which is the pair the old Status column could not show: a blocked name is answered with NOERROR, and reading only the code made a block look like a success. Both unhappy cases are marked by weight and shape as well as colour. Switching between History and Live keeps your filters, and leaving Live closes the stream instead of holding a viewer slot open. A live row that the log has not written yet opens its own provenance in place — no invented row id — and the open detail stays put while the 500-row buffer scrolls past underneath it. Domain testing moves to `/activity/test` as **Current policy simulation**, worded so it can never be misread as an account of a query that already happened.
|
||||||
|
- **Diagnostics can be scoped to an absolute window.** `/diagnostics?since=…&until=…` now validates and applies both bounds to the active and resolved lists, and the page states the window it is showing with a way to clear it. A query's detail page links here with the five minutes either side of that query, which is where the underlying failure text for a SERVFAIL lives.
|
||||||
|
- **The query log and the 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
|
||||||
|
|
||||||
|
One constant, chosen from the 0.0.7 field numbers: the checkpoint cadence was the last first-order write cost on the Pi's SD card.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The query log checkpoints its write-ahead log every 32 MiB instead of every 4 MiB.** Batching the writer in 0.0.7 took the deployed Pi from about 0.5 to 0.281 GiB of writes a day, and about 130 MiB of what is left is checkpoint writeback: SQLite's 1000-page default trips roughly every 40 minutes and rewrites the same hot index and interior pages into `querylog.db` each time. Every read-write connection to `querylog.db` now sets `wal_autocheckpoint` to 8192 pages, which stretches that to roughly five hours and cuts those in-place rewrites about eightfold, for an expected total near 190 MiB a day. The price is durability under power loss or a kernel panic. At `synchronous = NORMAL` a commit does not fsync, so the checkpoint is the only guaranteed durability boundary, and it now sits about five hours of query rows and upstream-history minutes back rather than 40 minutes. Kernel writeback normally makes the real loss far smaller than that, but nothing guarantees it. A process crash or a clean stop still loses nothing that was committed, and the database is never left inconsistent: recovery replays the longest valid prefix of the log. The `querylog.db-wal` file is expected to sit near 32 MiB rather than capped there, since a long-running reader can hold a checkpoint off and let it overshoot, and the daily retention pass still truncates it. `config.db` is unchanged.
|
||||||
|
|
||||||
|
## [0.0.7] - 2026-08-20
|
||||||
|
|
||||||
|
Operational failures get a page of their own, and the query log stops wearing out the disk it lives on: the deployed Pi was writing half a gigabyte a day to store two megabytes of query rows, one transaction per query. Both came out of running 0.0.6 on real hardware.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A diagnostics page.** Operational failures now land in one curated log instead of only journald: blocklist download failures, certificate reload failures, disk pressure, query-log writer and maintenance failures, upstream exchange and history failures, client tracking failures, listener and configuration problems at boot, and the query-log recreation an upgrade causes. One entry per failing subject — an entry opens on the first failure, counts repeats, and closes itself when the subject recovers; nothing needs dismissing. Each entry says what it means for the service and what to do about it. `GET /api/diagnostics` serves the log, `GET /api/health` reports the active counts and degrades while the diagnostics store itself cannot write, and `/metrics` gains `nxdns_diagnostics_active_warnings`, `nxdns_diagnostics_active_errors` and `nxdns_diagnostics_write_failures_total`. Resolved entries can be purged when you decide the history has served its purpose — one entry from its row or its detail page, or the whole resolved history at once with "Purge all resolved" (`DELETE /api/diagnostics/{id}` and `DELETE /api/diagnostics`). An entry that is still failing is the current state of the box, not history, so it has no purge action and the API answers 409.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The query log commits once a minute instead of once a query.** The writer batched for 100 milliseconds, which at a household's query rate means almost every query got a transaction of its own — and a transaction costs the disk far more than the row it carries. On the deployed Pi that came to roughly 0.5 GiB of writes a day to store 2.3 MB of query rows, the kind of write volume that kills an SD card. The batch window is now `logging.query_log_flush_interval_s`: 60 seconds by default (the same minute Pi-hole's `DBinterval` defaults to, for the same reason), anything from 0 to 3600, editable on the settings page. Batches are still capped at 100 rows, so a burst is committed as soon as it fills one rather than waiting out the window, and the in-memory queue, its drop-oldest backpressure and retention are untouched. The price is two kinds of lag: a crash costs about one interval of query history — more if the writer was held back by a full disk or a slow write — and every query-log-backed view — the query-log page, the dashboard totals, the timeseries — is about one interval behind. The live page is not affected; it is fed before the queue. Set the key to `0` for the old write-immediately behavior.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Shutdown no longer races the last query rows to the disk.** The query-log writer was stopped by the same cancellation that stopped the DNS listeners, so whether the batch it was holding reached the database depended on which happened to land first, the cancellation or the queue closing. Shutdown now stops and joins the listeners and every other query producer first, then closes the queue, then waits for the writer to finish emptying it — the held batch and everything still queued get written. If free space is below the critical threshold and the disk monitor will not let that final write through, the rows are counted as dropped instead of holding the exit open indefinitely.
|
||||||
|
- **An upstream success rate no longer rounds up to 100.0% while failures stand.** One decimal place cannot hold 12,696 successes out of 12,698 attempts: it rounded to `100.0%`, so the row claimed perfect reliability next to a failure count of 2. Neither end of the scale is reachable by rounding any more — `100.0%` needs an actual absence of failures and `0.0%` an actual absence of successes, and a rate a hair off either end shows `99.9%` or `0.1%` instead.
|
||||||
|
- **A query log set aside by a schema change is no longer named `corrupt`.** Every recreate wrote the old file to `querylog.db.corrupt-<unix seconds>`, whatever sent it there — including the fingerprint mismatch an upgrade causes, where the file is a healthy database this build simply cannot read. The name is the only account of the reason that outlives the log line, so it read as an accusation and invited operators to delete an intact file. The name now says which of the four cases it hit: `querylog.db.corrupt-…`, `.not-a-database-…`, `.quick-check-failed-…` or `.schema-changed-…`. The 0.0.6 upgrade produces `schema-changed`. Nothing else about the recreate changed, and no existing aside file is renamed.
|
||||||
|
|
||||||
|
## [0.0.6] - 2026-08-17
|
||||||
|
|
||||||
|
The period picker now scopes the whole dashboard. The upstream table was the last widget that ignored it, and fixing that meant recording upstream outcomes over time instead of counting them since boot. Read the query-log note below before you upgrade.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Four metrics for the new upstream-history recorder: `nxdns_upstream_history_flushes_total`, `nxdns_upstream_history_flush_failures_total`, `nxdns_upstream_history_rows_dropped_total` and the `nxdns_upstream_history_pending` gauge. While a flush to the database keeps failing, `GET /api/health` reports `degraded`; it recovers on the next flush that succeeds.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Upstream health answers for the selected period.** The dashboard's upstream table used to print counters accumulated since process start beside a success rate taken over the last 32 exchanges, which is how "63 failures" and "100.0% success rate" ended up in the same row under a period picker that scoped nothing there. Every upstream outcome is now aggregated into its wall-clock minute and written to `querylog.db`, and `GET /api/upstream/health?period=…` serves the selected window: attempts, failures, success rate, and the last failure with its error name, all inside the period, from 31 days of history. A window with no attempts reports no success rate at all instead of a perfect one, and the table shows an em-dash. The in-memory health state that drives failover and backoff is unchanged, as are its `/metrics` series.
|
||||||
|
- **`GET /api/upstream/health` changed shape.** Gone from each upstream: `consecutive_failures`, `total_successes`, `total_failures`, the last-32 `success_rate`, `last_error` and `last_error_age_s`. Each upstream keeps `url`, `enabled` and `available` and gains a `period` object with the ranged numbers; the body gains `period`, `since`, `until` and a `complete` flag that says whether any outcome was known to be dropped inside the window. The removed counters are still exported by `/metrics` under their existing names. On the dashboard the "Right now" section is gone with them: the upstream table rejoined the ranged part of the page, and the disk card, the one live widget left, is titled "Storage now".
|
||||||
|
- **The query log is recreated on upgrade.** Recording upstream history added two tables to the `querylog.db` schema, and its fingerprint check refuses a database that does not match the shipped definition. On first start this version renames the existing `querylog.db` aside as `querylog.db.corrupt-<unix seconds>` in the data directory and creates a fresh one, so query history and stats restart empty. The renamed file is left in place rather than deleted, so removing it is your call. `config.db` is untouched: no configuration is lost.
|
||||||
|
|
||||||
|
## [0.0.5] - 2026-08-16
|
||||||
|
|
||||||
|
One rendering fix on the 0.0.4 feature, caught the day it shipped.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The query tables no longer repeat the *learned* tag on every row: in the live page and the query log a learned name is just muted, with the address still in the row's tooltip. The clients page keeps the tag, where it appears once per client and says something.
|
||||||
|
|
||||||
## [0.0.4] - 2026-08-16
|
## [0.0.4] - 2026-08-16
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ Serves a household LAN (≈2–20 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.
|
- 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.
|
- DNS caching: positive + negative, in-memory only.
|
||||||
- Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments.
|
- 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.
|
- 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.
|
- 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)
|
### 2.2 Out of Scope (permanent scope decisions, not deferrals)
|
||||||
@@ -324,8 +324,10 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
|
|||||||
## 9. Upstream Resolution
|
## 9. Upstream Resolution
|
||||||
|
|
||||||
- Schemes: `https://…` → DoH, `tls://host:853` → DoT.
|
- Schemes: `https://…` → DoH, `tls://host:853` → DoT.
|
||||||
- Ordered by priority; sequential attempt; per-upstream failure counters; exponential backoff with jitter; success resets.
|
- Ordered by priority; one query tries upstreams sequentially; per-upstream failure counters; exponential backoff with jitter; success resets.
|
||||||
- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. Exposed via `GET /api/upstream/health`, dashboard, `/metrics`, and `nxdns check`.
|
- 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.
|
- 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.
|
- `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.
|
- Connect, read, and total-budget timeouts each configurable.
|
||||||
@@ -435,8 +437,28 @@ CREATE TABLE forward_zones (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
|
||||||
|
CREATE TABLE operational_events (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
subject_key TEXT NOT NULL,
|
||||||
|
subject_label TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
occurrences INTEGER NOT NULL CHECK (occurrences > 0),
|
||||||
|
resolved_at INTEGER,
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
CHECK (resolved_at IS NULL OR resolved_at >= first_seen)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_operational_events_active
|
||||||
|
ON operational_events(code, subject_key) WHERE resolved_at IS NULL;
|
||||||
|
CREATE INDEX idx_operational_events_last_seen
|
||||||
|
ON operational_events(last_seen DESC);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`operational_events` is the one table here that is **not** configuration. It is the diagnostics log of `src/storage/events.zig`: one row per failure episode, opened on the first failure and resolved when the same subject succeeds again. It is deliberately absent from `config_schema.table_names` and `config_schema.delete_order`, so `nxdns export` never emits it and `nxdns import` never wipes it.
|
||||||
|
|
||||||
### 11.3 querylog.db Schema
|
### 11.3 querylog.db Schema
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -465,7 +487,7 @@ CREATE INDEX idx_query_log_domain ON query_log(domain_id);
|
|||||||
### 11.4 Query Logger
|
### 11.4 Query Logger
|
||||||
|
|
||||||
- In-memory buffer, mutex guarded, hard cap `query_log_buffer_max` (default 10000).
|
- In-memory buffer, mutex guarded, hard cap `query_log_buffer_max` (default 10000).
|
||||||
- Flush: batch size (default 100) or max interval (default 100ms).
|
- Flush: batch size (100, comptime) or max interval `query_log_flush_interval_s` (default 60s, 0–3600, 0 = do not wait). One transaction per interval: at household query rates a per-query commit costs orders of magnitude more disk writes than the rows are worth. The interval is also roughly what a crash costs, while the writer is healthy and the disk gate is open — a gated or lock-delayed batch is older, so it is a normal case, not a bound.
|
||||||
- Privacy transforms (hide_domains / hide_client_ips) applied before persist + SSE fanout.
|
- Privacy transforms (hide_domains / hide_client_ips) applied before persist + SSE fanout.
|
||||||
- Backpressure: buffer full → drop oldest unflushed entry, increment monotonic `queries_dropped` (exposed in `/api/health` + `/metrics`). SSE fanout precedes buffer insert, so live viewers still see dropped-from-persistence entries.
|
- Backpressure: buffer full → drop oldest unflushed entry, increment monotonic `queries_dropped` (exposed in `/api/health` + `/metrics`). SSE fanout precedes buffer insert, so live viewers still see dropped-from-persistence entries.
|
||||||
|
|
||||||
@@ -514,7 +536,7 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
|
|||||||
### 13.1 Endpoints
|
### 13.1 Endpoints
|
||||||
|
|
||||||
- `POST /api/auth/login`, `POST /api/auth/logout`
|
- `POST /api/auth/login`, `POST /api/auth/logout`
|
||||||
- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…`
|
- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…`, `GET /api/stats/types?period=…`, `GET /api/stats/routes?period=…`, `GET /api/stats/clients?period=…`
|
||||||
- `GET /api/queries` (filter + paginate), `GET /api/queries/live` (SSE, per-IP cap)
|
- `GET /api/queries` (filter + paginate), `GET /api/queries/live` (SSE, per-IP cap)
|
||||||
- `GET/PUT /api/clients/{id}`
|
- `GET/PUT /api/clients/{id}`
|
||||||
- `GET/POST/PUT/DELETE /api/groups…`, `/api/blocklists…`, `/api/rules…`, `/api/local-records…`, `/api/forward-zones…`
|
- `GET/POST/PUT/DELETE /api/groups…`, `/api/blocklists…`, `/api/rules…`, `/api/local-records…`, `/api/forward-zones…`
|
||||||
@@ -522,9 +544,8 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
|
|||||||
- `GET /api/lookup?domain=…&group_id=…`
|
- `GET /api/lookup?domain=…&group_id=…`
|
||||||
- `GET/POST /api/pause`
|
- `GET/POST /api/pause`
|
||||||
- `GET/PUT /api/settings`
|
- `GET/PUT /api/settings`
|
||||||
- `GET /api/upstream/health`
|
|
||||||
- `POST /api/certs/reload`
|
- `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 /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`
|
- `GET /api/version`, `GET /api/openapi.yaml`
|
||||||
|
|
||||||
@@ -536,7 +557,7 @@ Hand-maintained `openapi.yaml`, served at `GET /api/openapi.yaml` and mirrored b
|
|||||||
|
|
||||||
## 14. Frontend
|
## 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.
|
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.
|
||||||
|
|
||||||
@@ -594,7 +615,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).
|
- **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.
|
- **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.
|
- **Integration**: UDP/TCP query path; blocked path; allow-over-block; wildcard precedence; CNAME uncloaking block; local records + forward zones; upstream failover/backoff/health; disk-full degradation; querylog.db corruption recovery; API CRUD; auth on/off; SSE; contract tests.
|
||||||
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log.
|
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; Overview + live log.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"typecheck": "tsc -b",
|
||||||
"lint": "oxlint src vite.config.ts",
|
"lint": "oxlint src vite.config.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
|||||||
@@ -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 = 800_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`,
|
||||||
|
);
|
||||||
@@ -87,8 +87,8 @@ test("429 login shows a ticking countdown and keeps submit disabled until it end
|
|||||||
|
|
||||||
test("safeRedirect only allows same-origin absolute paths", () => {
|
test("safeRedirect only allows same-origin absolute paths", () => {
|
||||||
expect(safeRedirect(undefined)).toBe("/");
|
expect(safeRedirect(undefined)).toBe("/");
|
||||||
expect(safeRedirect("/queries")).toBe("/queries");
|
expect(safeRedirect("/activity")).toBe("/activity");
|
||||||
expect(safeRedirect("/queries?x=1")).toBe("/queries?x=1");
|
expect(safeRedirect("/activity?x=1")).toBe("/activity?x=1");
|
||||||
expect(safeRedirect("//evil.example")).toBe("/");
|
expect(safeRedirect("//evil.example")).toBe("/");
|
||||||
expect(safeRedirect("https://evil.example")).toBe("/");
|
expect(safeRedirect("https://evil.example")).toBe("/");
|
||||||
expect(safeRedirect("/\\evil.example")).toBe("/");
|
expect(safeRedirect("/\\evil.example")).toBe("/");
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
import type { QueryDetail } from "@/lib/types";
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const payload = responses[String(input)];
|
||||||
|
if (payload === undefined) {
|
||||||
|
return new Response(JSON.stringify({ error: "no such query" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function renderDetail(id: number, search = "") {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(
|
||||||
|
createMemoryHistory({ initialEntries: [`/activity/queries/${id}${search}`] }),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The search parameters a link carries, so an assertion states them by name. */
|
||||||
|
function hrefSearch(link: HTMLElement): Record<string, string> {
|
||||||
|
const query = link.getAttribute("href")?.split("?")[1] ?? "";
|
||||||
|
return Object.fromEntries(new URLSearchParams(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The value beside a term, so a section's facts are read as pairs. */
|
||||||
|
function factValue(label: string): string {
|
||||||
|
const term = screen.getByText(label);
|
||||||
|
const value = term.nextElementSibling;
|
||||||
|
return value?.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The line under the domain, which is what a page is read as at a glance. */
|
||||||
|
function subtitle(domain: string): string {
|
||||||
|
const heading = screen.getByRole("heading", { name: domain });
|
||||||
|
return heading.nextElementSibling?.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a blocked query explains itself in the six sections, in pipeline order", async () => {
|
||||||
|
responses["/api/queries/42"] = detail(42, {
|
||||||
|
request: { time: 1_700_000_000, domain: "ads.example", client: "192.0.2.11", qtype: 28, qclass: 1 },
|
||||||
|
group: { id: 3, name: "kids" },
|
||||||
|
policy: {
|
||||||
|
action: "block",
|
||||||
|
reason: "blocklist_wildcard",
|
||||||
|
matched: "||tracker.example^",
|
||||||
|
source_id: 5,
|
||||||
|
source_name: "StevenBlack",
|
||||||
|
},
|
||||||
|
rewrites: { cname_target: "cdn.tracker.example" },
|
||||||
|
route: { kind: "blocked", upstream: "" },
|
||||||
|
response: { rcode: 0, duration_us: 1234 },
|
||||||
|
});
|
||||||
|
renderDetail(42);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "ads.example" });
|
||||||
|
|
||||||
|
const headings = screen.getAllByRole("heading", { level: 2 }).map((node) => node.textContent);
|
||||||
|
expect(headings).toEqual(["Request", "Group", "Policy", "Rewrites", "Route", "Response", "Related"]);
|
||||||
|
|
||||||
|
expect(factValue("Client")).toBe("192.0.2.11");
|
||||||
|
expect(factValue("Type")).toBe("AAAA");
|
||||||
|
expect(factValue("Class")).toBe("IN (1)");
|
||||||
|
expect(factValue("Name")).toBe("kids");
|
||||||
|
expect(factValue("Id")).toBe("3");
|
||||||
|
expect(factValue("Decision")).toBe("Blocked");
|
||||||
|
expect(factValue("Reason")).toBe("Blocklist (wildcard)");
|
||||||
|
expect(factValue("Matched")).toBe("||tracker.example^");
|
||||||
|
expect(factValue("Blocklist")).toBe("StevenBlack (#5)");
|
||||||
|
expect(factValue("CNAME target")).toBe("cdn.tracker.example");
|
||||||
|
expect(factValue("Answered by")).toBe("Blocked locally");
|
||||||
|
expect(factValue("Upstream")).toBe("No upstream exchange");
|
||||||
|
expect(factValue("Result")).toBe("NOERROR (0)");
|
||||||
|
expect(factValue("Took")).toBe("1.2 ms");
|
||||||
|
// NOERROR is the ordinary case and adds nothing to the verdict.
|
||||||
|
expect(subtitle("ads.example")).toMatch(/ — Blocked$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an upstream SERVFAIL names the resolver that failed and the code the client saw", async () => {
|
||||||
|
responses["/api/queries/7"] = detail(7, {
|
||||||
|
request: { domain: "news.example" },
|
||||||
|
route: { kind: "upstream", upstream: "https://dns.example/dns-query" },
|
||||||
|
response: { rcode: 2, duration_us: null },
|
||||||
|
});
|
||||||
|
renderDetail(7);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "news.example" });
|
||||||
|
expect(factValue("Answered by")).toBe("Upstream resolver");
|
||||||
|
expect(factValue("Upstream")).toBe("https://dns.example/dns-query");
|
||||||
|
expect(factValue("Result")).toBe("SERVFAIL (2)");
|
||||||
|
expect(factValue("Took")).toBe("Not measured");
|
||||||
|
// The policy allowed the query; the client still got nothing, and the
|
||||||
|
// headline has to say so rather than reading as a success.
|
||||||
|
expect(subtitle("news.example")).toMatch(/ — Allowed — SERVFAIL \(2\)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a forward-zone answer says the matcher never ran, not that nothing matched", async () => {
|
||||||
|
responses["/api/queries/14"] = detail(14, {
|
||||||
|
request: { domain: "nas.lan.home" },
|
||||||
|
policy: { action: "allow", reason: "forward_zone", matched: "" },
|
||||||
|
route: { kind: "forward_zone", forward_zone: "lan.home", upstream: "udp://192.168.1.1:53" },
|
||||||
|
});
|
||||||
|
renderDetail(14);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "nas.lan.home" });
|
||||||
|
expect(factValue("Reason")).toBe("Forward zone");
|
||||||
|
expect(factValue("Matched")).toBe("The matcher never ran");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a query the matcher did evaluate keeps the honest empty verdict", async () => {
|
||||||
|
responses["/api/queries/15"] = detail(15, {
|
||||||
|
policy: { action: "allow", reason: "no_match", matched: "" },
|
||||||
|
});
|
||||||
|
renderDetail(15);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "example.com" });
|
||||||
|
expect(factValue("Reason")).toBe("No match");
|
||||||
|
expect(factValue("Matched")).toBe("Nothing matched");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty text fields read as absent facts, never as blank values", async () => {
|
||||||
|
responses["/api/queries/8"] = detail(8, {
|
||||||
|
group: { id: null, name: "" },
|
||||||
|
policy: { action: "not_evaluated", reason: "paused", matched: "", source_id: null, source_name: "" },
|
||||||
|
route: { kind: "upstream", forward_zone: "", upstream: "udp://9.9.9.9:53" },
|
||||||
|
});
|
||||||
|
renderDetail(8);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "example.com" });
|
||||||
|
expect(factValue("Name")).toBe("No group recorded");
|
||||||
|
expect(factValue("Matched")).toBe("The matcher never ran");
|
||||||
|
expect(factValue("Blocklist")).toBe("Not a blocklist decision");
|
||||||
|
expect(factValue("Safe search")).toBe("No rewrite");
|
||||||
|
expect(factValue("Reason")).toBe("Filtering paused");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a log with hidden domains renders the server's marker, with nothing invented around it", async () => {
|
||||||
|
responses["/api/queries/9"] = detail(9, {
|
||||||
|
request: { domain: "hidden" },
|
||||||
|
policy: { action: "block", reason: "blocklist_domain", matched: "hidden", source_name: "StevenBlack" },
|
||||||
|
rewrites: { cname_target: "hidden", safe_search_target: "hidden" },
|
||||||
|
route: { kind: "blocked", upstream: "" },
|
||||||
|
});
|
||||||
|
renderDetail(9);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "hidden" });
|
||||||
|
expect(factValue("Domain")).toBe("hidden");
|
||||||
|
expect(factValue("Matched")).toBe("hidden");
|
||||||
|
expect(factValue("CNAME target")).toBe("hidden");
|
||||||
|
expect(factValue("Safe search")).toBe("hidden");
|
||||||
|
// The client is governed by its own flag and stays visible here.
|
||||||
|
expect(factValue("Client")).toBe("192.0.2.10");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the related actions carry absolute bounds around the query, and the domain into the simulation", async () => {
|
||||||
|
responses["/api/queries/11"] = detail(11, {
|
||||||
|
request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
|
||||||
|
});
|
||||||
|
renderDetail(11);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "shop.example" });
|
||||||
|
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
||||||
|
expect(
|
||||||
|
within(related)
|
||||||
|
.getByRole("link", { name: /Test this domain/ })
|
||||||
|
.getAttribute("href"),
|
||||||
|
).toBe("/activity/test?domain=shop.example");
|
||||||
|
// No origin bound at all: five minutes either side of the query itself.
|
||||||
|
expect(hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }))).toEqual({
|
||||||
|
mode: "history",
|
||||||
|
domain: "shop.example",
|
||||||
|
since: "1699999700",
|
||||||
|
until: "1700000300",
|
||||||
|
});
|
||||||
|
expect(hrefSearch(within(related).getByRole("link", { name: "All activity from this client" }))).toEqual({
|
||||||
|
mode: "history",
|
||||||
|
client: "192.0.2.12",
|
||||||
|
since: "1699999700",
|
||||||
|
until: "1700000300",
|
||||||
|
});
|
||||||
|
// The diagnostics window is the query's own moment, never the origin's.
|
||||||
|
expect(hrefSearch(within(related).getByRole("link", { name: /Diagnostics around/ }))).toEqual({
|
||||||
|
since: "1699999700",
|
||||||
|
until: "1700000300",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an origin bound wins over the default window, one bound at a time", async () => {
|
||||||
|
responses["/api/queries/17"] = detail(17, {
|
||||||
|
request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
|
||||||
|
});
|
||||||
|
renderDetail(17, "?mode=history&since=1600000000");
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "shop.example" });
|
||||||
|
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
||||||
|
const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
|
||||||
|
expect(link["since"]).toBe("1600000000");
|
||||||
|
expect(link["until"]).toBe("1700000300");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both origin bounds carry through untouched", async () => {
|
||||||
|
responses["/api/queries/18"] = detail(18, { request: { time: 1_700_000_000, domain: "shop.example" } });
|
||||||
|
renderDetail(18, "?mode=history&since=1600000000&until=1600000060");
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "shop.example" });
|
||||||
|
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
||||||
|
const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
|
||||||
|
expect(link["since"]).toBe("1600000000");
|
||||||
|
expect(link["until"]).toBe("1600000060");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the back link restores the investigation the reader came from", async () => {
|
||||||
|
responses["/api/queries/19"] = detail(19, { request: { domain: "shop.example" } });
|
||||||
|
renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "shop.example" });
|
||||||
|
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
||||||
|
mode: "history",
|
||||||
|
domain: "shop",
|
||||||
|
since: "1600000000",
|
||||||
|
blocked: "true",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function clientList(client: { ip: string; name: string; learned_name: string }) {
|
||||||
|
return {
|
||||||
|
clients: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
group_id: 1,
|
||||||
|
group: "default",
|
||||||
|
hand_edited: client.name !== "",
|
||||||
|
first_seen: 1_700_000_000,
|
||||||
|
last_seen: 1_700_000_100,
|
||||||
|
...client,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The related section, whose text is read whole because it is prose, not facts. */
|
||||||
|
async function relatedText(expected: string) {
|
||||||
|
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
||||||
|
await waitFor(() => expect(related.textContent?.replace(/\s+/g, " ")).toContain(expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the record keeps the address the query came from, and Related carries the name it has now", async () => {
|
||||||
|
responses["/api/queries/12"] = detail(12, { request: { domain: "shop.example", client: "192.0.2.12" } });
|
||||||
|
responses["/api/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" });
|
||||||
|
renderDetail(12);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "shop.example" });
|
||||||
|
await relatedText("The client list currently names 192.0.2.12 “Kids iPad”.");
|
||||||
|
expect(factValue("Client")).toBe("192.0.2.12");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a learned name is told as the reverse-DNS lookup it is, never as a recorded fact", async () => {
|
||||||
|
responses["/api/queries/13"] = detail(13, { request: { client: "192.0.2.13" } });
|
||||||
|
responses["/api/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" });
|
||||||
|
renderDetail(13);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "example.com" });
|
||||||
|
await relatedText("Reverse DNS currently resolves 192.0.2.13 to printer.lan.");
|
||||||
|
expect(factValue("Client")).toBe("192.0.2.13");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => {
|
||||||
|
renderDetail(404, "?mode=history&domain=gone");
|
||||||
|
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
expect(screen.getByText(/no such query/)).toBeTruthy();
|
||||||
|
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
||||||
|
mode: "history",
|
||||||
|
domain: "gone",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The related-actions region of a query detail. Scoped on purpose: the sidebar
|
||||||
|
* carries a Pause of its own, and this is the one that answers "this query was
|
||||||
|
* blocked and should not have been".
|
||||||
|
*/
|
||||||
|
function related(): HTMLElement {
|
||||||
|
return screen.getByRole("region", { name: "Related" });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => {
|
||||||
|
responses["/api/queries/50"] = detail(50, {
|
||||||
|
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
||||||
|
route: { kind: "blocked", upstream: "" },
|
||||||
|
});
|
||||||
|
renderDetail(50);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "example.com" });
|
||||||
|
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } });
|
||||||
|
renderDetail(51);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "example.com" });
|
||||||
|
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Pause action stays away while protection is unavailable", async () => {
|
||||||
|
responses["/api/health"] = health({ protection: { state: "unavailable", until: null } });
|
||||||
|
responses["/api/queries/52"] = detail(52, {
|
||||||
|
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
||||||
|
route: { kind: "blocked", upstream: "" },
|
||||||
|
});
|
||||||
|
renderDetail(52);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "example.com" });
|
||||||
|
await waitFor(() => expect(screen.getByText("Diagnostics around this query")).toBeTruthy());
|
||||||
|
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, useParams, useSearch } from "@tanstack/react-router";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import { queryDetailQuery } from "@/lib/queries";
|
||||||
|
import type { QueryDetail } from "@/lib/types";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import ProvenanceDetail from "./ProvenanceDetail";
|
||||||
|
import RelatedActions from "./RelatedActions";
|
||||||
|
import type { ActivitySearch } from "./search";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
back: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
textDecorationLine: "none",
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The way back to the investigation, not to a bare list. The originating
|
||||||
|
* Activity search rides in this route's own search, so the reader returns to
|
||||||
|
* the mode, the filters and the absolute window they left — a plain `/activity`
|
||||||
|
* would silently widen the range they had chosen.
|
||||||
|
*/
|
||||||
|
function BackLink({ origin }: { origin: ActivitySearch }) {
|
||||||
|
return (
|
||||||
|
<Link to="/activity" search={origin} {...stylex.props(styles.back, shared.focusRing)}>
|
||||||
|
← Activity
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityDetailPage() {
|
||||||
|
const { id } = useParams({ from: "/shell/activity/queries/$id" });
|
||||||
|
const origin = useSearch({ from: "/shell/activity/queries/$id" });
|
||||||
|
const rowId = Number(id);
|
||||||
|
const { data, error, isPending, refetch } = useQuery(queryDetailQuery(rowId));
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
|
||||||
|
Loading query…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data === undefined) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<BackLink origin={origin} />
|
||||||
|
<InlineError error={error} onRetry={() => void refetch()} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail: QueryDetail = data;
|
||||||
|
const { domain, client, time } = detail.request;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<BackLink origin={origin} />
|
||||||
|
<ProvenanceDetail
|
||||||
|
provenance={detail}
|
||||||
|
persistedId={detail.id}
|
||||||
|
relatedActions={
|
||||||
|
<RelatedActions
|
||||||
|
domain={domain}
|
||||||
|
client={client}
|
||||||
|
ts={time}
|
||||||
|
origin={origin}
|
||||||
|
blocked={detail.policy.action === "block"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* The filter row over the Activity table.
|
||||||
|
*
|
||||||
|
* The applied state is the URL, never this form: what the reader sees is what
|
||||||
|
* the link they can paste to a housemate will show. So this holds a draft only,
|
||||||
|
* and the page remounts it whenever the applied search changes — a back button
|
||||||
|
* or a pasted URL has to move the form with it, and a form that seeded itself
|
||||||
|
* once would keep showing the previous investigation's filters.
|
||||||
|
*
|
||||||
|
* In live mode the row stays visible and disabled rather than disappearing: the
|
||||||
|
* filters are retained in the URL and apply again the moment history comes
|
||||||
|
* back, and hiding them would read as having lost them. The stream itself is
|
||||||
|
* unfiltered — the server sends every query — so a row that looked usable here
|
||||||
|
* would promise filtering that is not happening.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import Select from "@/ui/Select";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||||
|
import type { ActivitySearch } from "./search";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "any", label: "All" },
|
||||||
|
{ value: "blocked", label: "Blocked only" },
|
||||||
|
{ value: "allowed", label: "Allowed only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
/** One column on a phone, two from `sm`, five from `lg`. */
|
||||||
|
grid: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
display: "grid",
|
||||||
|
gap: "0.75rem",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: "repeat(1, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
width: "100%",
|
||||||
|
// A disabled native input keeps its value legible but reads as inert,
|
||||||
|
// matching what RAC does to the Select trigger beside it.
|
||||||
|
cursor: { default: null, ":disabled": "not-allowed" },
|
||||||
|
opacity: { default: null, ":disabled": 0.55 },
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "0.5rem",
|
||||||
|
gridColumn: {
|
||||||
|
default: null,
|
||||||
|
"@media (min-width: 640px)": "span 2 / span 2",
|
||||||
|
"@media (min-width: 1024px)": "span 5 / span 5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toolbarButton: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The applied filters, with `mode` left to the page that owns the switch. */
|
||||||
|
export type AppliedFilters = Omit<ActivitySearch, "mode">;
|
||||||
|
|
||||||
|
/** Every filter off — what Clear applies, and the loader's empty-filter case. */
|
||||||
|
export const NO_FILTERS: AppliedFilters = {
|
||||||
|
domain: undefined,
|
||||||
|
client: undefined,
|
||||||
|
blocked: undefined,
|
||||||
|
since: undefined,
|
||||||
|
until: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
function blockedOption(blocked: boolean | undefined): string {
|
||||||
|
if (blocked === undefined) return "any";
|
||||||
|
return blocked ? "blocked" : "allowed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionBlocked(value: string): boolean | undefined {
|
||||||
|
if (value === "blocked") return true;
|
||||||
|
return value === "allowed" ? false : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
|
||||||
|
return reason === "unparseable"
|
||||||
|
? `${label} is not a complete date and time.`
|
||||||
|
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applied: AppliedFilters;
|
||||||
|
isDisabled: boolean;
|
||||||
|
onApply: (filters: AppliedFilters) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
|
||||||
|
const [domain, setDomain] = useState(applied.domain ?? "");
|
||||||
|
const [client, setClient] = useState(applied.client ?? "");
|
||||||
|
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
||||||
|
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
|
||||||
|
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const sinceValue = resolveDatetimeField(since);
|
||||||
|
if (!sinceValue.ok) {
|
||||||
|
setError(boundError("Since", sinceValue.reason));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const untilValue = resolveDatetimeField(until);
|
||||||
|
if (!untilValue.ok) {
|
||||||
|
setError(boundError("Until", untilValue.reason));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
onApply({
|
||||||
|
domain: domain.trim() === "" ? undefined : domain.trim(),
|
||||||
|
client: client.trim() === "" ? undefined : client.trim(),
|
||||||
|
blocked: optionBlocked(blocked),
|
||||||
|
since: sinceValue.value,
|
||||||
|
until: untilValue.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
setDomain("");
|
||||||
|
setClient("");
|
||||||
|
setBlocked("any");
|
||||||
|
setSince(datetimeField(undefined));
|
||||||
|
setUntil(datetimeField(undefined));
|
||||||
|
setError(null);
|
||||||
|
onClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form onSubmit={submit} {...stylex.props(styles.grid)}>
|
||||||
|
<label {...stylex.props(styles.label)}>
|
||||||
|
Domain contains
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={domain}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(event) => setDomain(event.target.value)}
|
||||||
|
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label {...stylex.props(styles.label)}>
|
||||||
|
Client (exact)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={client}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(event) => setClient(event.target.value)}
|
||||||
|
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
variant="compactField"
|
||||||
|
label="Result"
|
||||||
|
value={blocked}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onChange={setBlocked}
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
/>
|
||||||
|
<label {...stylex.props(styles.label)}>
|
||||||
|
Since
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
step={1}
|
||||||
|
value={since.text}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
||||||
|
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label {...stylex.props(styles.label)}>
|
||||||
|
Until
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
step={1}
|
||||||
|
value={until.text}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
||||||
|
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isDisabled}
|
||||||
|
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Apply filters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
disabled={isDisabled}
|
||||||
|
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{error !== null && (
|
||||||
|
<p role="alert" {...stylex.props(styles.error)}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
/**
|
||||||
|
* Activity in history mode, through the real router: the URL is the applied
|
||||||
|
* state, so nothing here can be checked by rendering the page on its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
import { health } from "@/lib/healthFixture";
|
||||||
|
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||||
|
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||||
|
|
||||||
|
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
ip,
|
||||||
|
name,
|
||||||
|
learned_name: learnedName,
|
||||||
|
group_id: 1,
|
||||||
|
group: "default",
|
||||||
|
hand_edited: name !== "",
|
||||||
|
first_seen: 1_700_000_000,
|
||||||
|
last_seen: 1_700_000_100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENTS: Client[] = [
|
||||||
|
client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||||
|
client(2, "192.0.2.11", "", "laptop.lan"),
|
||||||
|
client(3, "192.0.2.12", "", ""),
|
||||||
|
];
|
||||||
|
|
||||||
|
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||||
|
return queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides });
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 };
|
||||||
|
|
||||||
|
/** The blocked row every page fixture reuses. */
|
||||||
|
const BLOCKED = {
|
||||||
|
blocked: true,
|
||||||
|
policy_action: "block",
|
||||||
|
policy_reason: "blocklist_wildcard",
|
||||||
|
route_kind: "blocked",
|
||||||
|
upstream: "",
|
||||||
|
} as const satisfies Partial<QueryRow>;
|
||||||
|
|
||||||
|
const PAGES: Record<string, QueriesPage> = {
|
||||||
|
"/api/queries": {
|
||||||
|
queries: [
|
||||||
|
row(20, "first.example", { qtype: 65, cache_hit: true, route_kind: "cache" }),
|
||||||
|
row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }),
|
||||||
|
],
|
||||||
|
next_before: 19,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
},
|
||||||
|
"/api/queries?before=19": {
|
||||||
|
queries: [row(5, "older.example")],
|
||||||
|
next_before: null,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
},
|
||||||
|
"/api/queries?domain=ads": {
|
||||||
|
queries: [row(19, "ads.example", BLOCKED)],
|
||||||
|
next_before: null,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
},
|
||||||
|
"/api/queries?domain=ads&blocked=true": {
|
||||||
|
queries: [row(19, "ads.example", BLOCKED)],
|
||||||
|
next_before: null,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
},
|
||||||
|
"/api/queries?since=1700000000": {
|
||||||
|
queries: [row(20, "first.example")],
|
||||||
|
next_before: null,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function json(payload: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||||
|
|
||||||
|
/** The shell's own requests, which every test serves the same way. */
|
||||||
|
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
|
||||||
|
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromPages(url: string): Response {
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
|
const payload = PAGES[url];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return json(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch(fromPages);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage(path = "/activity") {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const history = createMemoryHistory({ initialEntries: [path] });
|
||||||
|
const router = createAppRouter(history, queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
return { queryClient, history };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every `/api/queries` URL the run asked for, list pages only. */
|
||||||
|
function queryCalls(): string[] {
|
||||||
|
return fetchMock.mock.calls
|
||||||
|
.map((call) => String(call[0]))
|
||||||
|
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders the first page with the seven columns filled in", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
|
||||||
|
"Time",
|
||||||
|
"Domain",
|
||||||
|
"Client",
|
||||||
|
"Type",
|
||||||
|
"Result",
|
||||||
|
"Route",
|
||||||
|
"Duration",
|
||||||
|
]);
|
||||||
|
const first = screen.getByText("first.example").closest("tr")!;
|
||||||
|
expect(within(first).getByText("HTTPS")).toBeTruthy();
|
||||||
|
expect(within(first).getByText("NOERROR")).toBeTruthy();
|
||||||
|
expect(within(first).getByText("Cache")).toBeTruthy();
|
||||||
|
expect(within(first).getByText("1.2 ms")).toBeTruthy();
|
||||||
|
|
||||||
|
const blocked = screen.getByText("ads.example").closest("tr")!;
|
||||||
|
// The Result cell says Blocked even though the client saw NOERROR, and the
|
||||||
|
// Route cell says how: this is the pair the old Status column could not show.
|
||||||
|
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
|
||||||
|
expect(within(blocked).getByText("—")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
|
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return json({
|
||||||
|
queries: [
|
||||||
|
row(20, "named.example", { client_ip: "192.0.2.10" }),
|
||||||
|
row(19, "learned.example", { client_ip: "192.0.2.11" }),
|
||||||
|
row(18, "nameless.example", { client_ip: "192.0.2.12" }),
|
||||||
|
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
|
||||||
|
],
|
||||||
|
next_before: null,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
} satisfies QueriesPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||||
|
const named = await screen.findByText("Kitchen Pi");
|
||||||
|
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||||
|
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||||
|
|
||||||
|
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||||
|
// repeat on every row of the table, so the Clients page carries it instead.
|
||||||
|
const learned = screen.getByText("laptop.lan");
|
||||||
|
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||||
|
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||||
|
|
||||||
|
// A known client with neither name, and a client the loaded list has never
|
||||||
|
// seen, both fall back to the bare address with no tooltip standing in.
|
||||||
|
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||||
|
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("load more appends the next page and stops at the end of the log", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
await screen.findByText("older.example");
|
||||||
|
|
||||||
|
expect(screen.getByText("first.example")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applying a filter puts it in the url, refetches, and resets the accumulated list", async () => {
|
||||||
|
const { history } = renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
await screen.findByText("older.example");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
|
||||||
|
await screen.findByText(/Showing 1 query /);
|
||||||
|
expect(history.location.search).toContain("domain=ads");
|
||||||
|
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("first.example")).toBeNull();
|
||||||
|
expect(screen.queryByText("older.example")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a load-more that resolves after a filter change is discarded", async () => {
|
||||||
|
let releaseLoadMore: () => void = () => {};
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/queries?before=19") {
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
releaseLoadMore = () => resolve(json(PAGES["/api/queries?before=19"]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fromPages(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
await screen.findByText(/Showing 1 query /);
|
||||||
|
|
||||||
|
releaseLoadMore();
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText("older.example")).toBeNull();
|
||||||
|
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
|
||||||
|
let releaseFiltered: () => void = () => {};
|
||||||
|
const filteredPage: QueriesPage = {
|
||||||
|
queries: [row(19, "ads.example", BLOCKED)],
|
||||||
|
next_before: 7,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
};
|
||||||
|
const filteredOlderPage: QueriesPage = {
|
||||||
|
queries: [row(3, "ads.older.example")],
|
||||||
|
next_before: null,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
};
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/queries?domain=ads") {
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
releaseFiltered = () => resolve(json(filteredPage));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === "/api/queries?domain=ads&before=7") return json(filteredOlderPage);
|
||||||
|
return fromPages(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
|
||||||
|
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
||||||
|
expect(staleButton).toHaveProperty("disabled", true);
|
||||||
|
fireEvent.click(staleButton);
|
||||||
|
expect(queryCalls()).not.toContain("/api/queries?domain=ads&before=19");
|
||||||
|
|
||||||
|
releaseFiltered();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("first.example")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
const freshButton = screen.getByRole("button", { name: "Load more" });
|
||||||
|
expect(freshButton).toHaveProperty("disabled", false);
|
||||||
|
fireEvent.click(freshButton);
|
||||||
|
await screen.findByText("ads.older.example");
|
||||||
|
|
||||||
|
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
|
||||||
|
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||||
|
// The newest-100 window moves up while the reader has a second page open.
|
||||||
|
// Refetching only the first page would drop n20 and n19 out of the middle
|
||||||
|
// of the table; the second page must be replayed from the fresh cursor.
|
||||||
|
const before: Record<string, QueriesPage> = {
|
||||||
|
"/api/queries": {
|
||||||
|
queries: [row(20, "n20.example"), row(19, "n19.example")],
|
||||||
|
next_before: 19,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
},
|
||||||
|
"/api/queries?before=19": {
|
||||||
|
queries: [row(18, "n18.example"), row(17, "n17.example")],
|
||||||
|
next_before: null,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const after: Record<string, QueriesPage> = {
|
||||||
|
"/api/queries": {
|
||||||
|
queries: [row(22, "n22.example"), row(21, "n21.example")],
|
||||||
|
next_before: 21,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
},
|
||||||
|
"/api/queries?before=21": {
|
||||||
|
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
|
||||||
|
next_before: null,
|
||||||
|
coverage: COMPLETE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let live = before;
|
||||||
|
stubFetch((url) => {
|
||||||
|
const payload = live[url];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return json(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { queryClient } = renderPage();
|
||||||
|
await screen.findByText("n20.example");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
await screen.findByText("n17.example");
|
||||||
|
|
||||||
|
live = after;
|
||||||
|
await act(async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["queries"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
await screen.findByText("n22.example");
|
||||||
|
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||||
|
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||||
|
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||||
|
const assign = vi.fn();
|
||||||
|
vi.stubGlobal("location", { pathname: "/activity", search: "", assign });
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/queries?before=19") {
|
||||||
|
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fromPages(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/activity")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
expect(screen.queryByText(/Failed to load more/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each row links into its own detail page, carrying the investigation with it", async () => {
|
||||||
|
renderPage("/activity?mode=history&since=1700000000");
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "first.example" });
|
||||||
|
expect(link.getAttribute("href")).toContain("/activity/queries/20");
|
||||||
|
expect(link.getAttribute("href")).toContain("since=1700000000");
|
||||||
|
// An <a href> is in the tab order by default; nothing here may opt it out.
|
||||||
|
expect(link.getAttribute("tabindex")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pruned window tells the reader when history starts", async () => {
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
|
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return json({
|
||||||
|
queries: [row(20, "kept.example")],
|
||||||
|
next_before: null,
|
||||||
|
coverage: { complete: false, available_since: 1_700_000_000 },
|
||||||
|
} satisfies QueriesPage);
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText("kept.example");
|
||||||
|
expect(screen.getByText(/Query history is available from/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a complete window shows no coverage notice", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
expect(screen.queryByText(/Query history is available from/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a ?domain= link seeds the filter form and fetches that domain on arrival", async () => {
|
||||||
|
renderPage("/activity?domain=ads");
|
||||||
|
|
||||||
|
await screen.findByText("ads.example");
|
||||||
|
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
||||||
|
expect(screen.queryByText("first.example")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("history forwards exactly the six normalized filter fields and nothing else", async () => {
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
|
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||||
|
});
|
||||||
|
renderPage(
|
||||||
|
"/activity?mode=history&domain=%20ads%20&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600&bogus=1&limit=9999",
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByText("No queries match the current filters.");
|
||||||
|
expect(queryCalls()).toEqual([
|
||||||
|
"/api/queries?domain=ads&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rejected search parameter is dropped rather than guessed at", async () => {
|
||||||
|
renderPage("/activity?since=1.5&blocked=%22true%22&domain=%20%20");
|
||||||
|
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
// Nothing survived validation, so the request is the unfiltered one.
|
||||||
|
expect(queryCalls()).toEqual(["/api/queries"]);
|
||||||
|
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the form draft follows the url back and forward, seconds included", async () => {
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
|
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||||
|
});
|
||||||
|
// A bound with non-zero seconds: the round trip has to keep them, and the
|
||||||
|
// untouched field has to carry the original number rather than re-parse.
|
||||||
|
const seeded = 1_700_000_017;
|
||||||
|
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
||||||
|
|
||||||
|
const domainInput = await screen.findByLabelText("Domain contains");
|
||||||
|
expect(domainInput).toHaveProperty("value", "first");
|
||||||
|
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
||||||
|
expect(sinceInput.value).toContain(":37");
|
||||||
|
|
||||||
|
fireEvent.change(domainInput, { target: { value: "second" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
||||||
|
// The untouched Since bound applied as the exact second it was seeded with.
|
||||||
|
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
|
||||||
|
|
||||||
|
act(() => history.back());
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => history.forward());
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 2 a.m. on the EU spring-forward date: an hour that exists in some zones and not others. */
|
||||||
|
const DST_WALL_TIME = "2026-03-29T02:30:00";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether that wall time names an instant in the timezone the suite runs in.
|
||||||
|
* `new Date` slides a spring-forward gap silently forward, so an hour or minute
|
||||||
|
* that comes back different from the one written *is* the gap.
|
||||||
|
*/
|
||||||
|
function wallTimeExists(text: string): boolean {
|
||||||
|
const written = /T(\d{2}):(\d{2})/.exec(text)!;
|
||||||
|
const parsed = new Date(text);
|
||||||
|
return parsed.getHours() === Number(written[1]) && parsed.getMinutes() === Number(written[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a wall-clock time the daylight-saving jump skips is refused, not silently moved", async () => {
|
||||||
|
// One expected outcome per timezone, decided here rather than accepted from
|
||||||
|
// the page: in a zone with the jump the bound must be refused outright, and
|
||||||
|
// in a zone without it the same text is an ordinary instant that applies.
|
||||||
|
const inGap = !wallTimeExists(DST_WALL_TIME);
|
||||||
|
const unix = Math.floor(new Date(DST_WALL_TIME).getTime() / 1000);
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === `/api/queries?since=${unix}`) {
|
||||||
|
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||||
|
}
|
||||||
|
return fromPages(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { history } = renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
const callsBefore = queryCalls().length;
|
||||||
|
const searchBefore = history.location.search;
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
|
||||||
|
if (inGap) {
|
||||||
|
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
||||||
|
// Refused means refused: no navigation, and no request for the hour the
|
||||||
|
// operator did not ask for.
|
||||||
|
expect(history.location.search).toBe(searchBefore);
|
||||||
|
expect(queryCalls()).toHaveLength(callsBefore);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitFor(() => expect(history.location.search).toContain(`since=${unix}`));
|
||||||
|
expect(queryCalls()).toContain(`/api/queries?since=${unix}`);
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the policy simulation is reachable from the header, with no rows to click through", async () => {
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
|
if (url === "/api/groups") return json({ groups: [{ id: 1, name: "default", safe_search: false }] });
|
||||||
|
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { history } = renderPage();
|
||||||
|
// An empty log is exactly the case a row-borne link cannot serve.
|
||||||
|
await screen.findByText("No queries logged yet.");
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "Current policy simulation" });
|
||||||
|
expect(link.getAttribute("href")).toBe("/activity/test");
|
||||||
|
|
||||||
|
fireEvent.click(link);
|
||||||
|
await screen.findByRole("heading", { level: 1, name: "Current policy simulation" });
|
||||||
|
expect(history.location.pathname).toBe("/activity/test");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Clear empties the url as well as the form", async () => {
|
||||||
|
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||||
|
await screen.findByText("ads.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(history.location.search).not.toContain("domain");
|
||||||
|
});
|
||||||
|
expect(history.location.search).not.toContain("blocked");
|
||||||
|
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Activity: one surface over the queries nxdns answered, in two modes.
|
||||||
|
*
|
||||||
|
* History reads the persisted log and Live reads the stream, but they are the
|
||||||
|
* same seven columns over the same filters, and the reader moves between them
|
||||||
|
* without losing the question they were asking. The mode lives in the URL with
|
||||||
|
* the filters, so an investigation is one link — including which half of it the
|
||||||
|
* recipient should be looking at.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||||
|
import HistoryActivity from "./HistoryActivity";
|
||||||
|
import LiveActivity from "./LiveActivity";
|
||||||
|
import type { ActivityMode } from "./search";
|
||||||
|
|
||||||
|
const MODES: ReadonlyArray<{ mode: ActivityMode; label: string }> = [
|
||||||
|
{ mode: "history", label: "History" },
|
||||||
|
{ mode: "live", label: "Live" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
header: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
switch: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.25rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: "0.125rem",
|
||||||
|
},
|
||||||
|
modeButton: {
|
||||||
|
borderStyle: "none",
|
||||||
|
borderRadius: "0.1875rem",
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
modeIdle: {
|
||||||
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
|
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||||
|
},
|
||||||
|
/** The selected mode reads as a filled chip, the same weight the nav uses. */
|
||||||
|
modeSelected: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
color: colors.primaryText,
|
||||||
|
},
|
||||||
|
/** The one way into the simulation from here, so it cannot sit behind a row. */
|
||||||
|
simulationLink: {
|
||||||
|
marginInlineStart: "auto",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
textDecorationLine: "none",
|
||||||
|
},
|
||||||
|
liveNote: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ActivityPage() {
|
||||||
|
const search = useSearch({ from: "/shell/activity" });
|
||||||
|
const navigate = useNavigate({ from: "/activity" });
|
||||||
|
const live = search.mode === "live";
|
||||||
|
|
||||||
|
// The functional form, not a replacement object: the filters are retained
|
||||||
|
// across a mode switch on purpose, and spelling out a new search here would
|
||||||
|
// drop every one of them on the way to Live and back.
|
||||||
|
function selectMode(mode: ActivityMode) {
|
||||||
|
if (mode === search.mode) return;
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(filters: AppliedFilters) {
|
||||||
|
void navigate({ search: { mode: search.mode, ...filters } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div {...stylex.props(styles.header)}>
|
||||||
|
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
||||||
|
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
|
||||||
|
{MODES.map((option) => {
|
||||||
|
const selected = option.mode === search.mode;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.mode}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => selectMode(option.mode)}
|
||||||
|
{...stylex.props(
|
||||||
|
styles.modeButton,
|
||||||
|
selected ? styles.modeSelected : styles.modeIdle,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
||||||
|
Current policy simulation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Remounted whenever the applied search changes, which is what makes
|
||||||
|
* the back button work: the draft is derived state, and the browser
|
||||||
|
* moving the URL under it has to move the form with it.
|
||||||
|
*/}
|
||||||
|
<ActivityFilters
|
||||||
|
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
|
||||||
|
applied={search}
|
||||||
|
isDisabled={live}
|
||||||
|
onApply={apply}
|
||||||
|
onClear={() => apply(NO_FILTERS)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{live ? (
|
||||||
|
<>
|
||||||
|
<p {...stylex.props(styles.liveNote)}>
|
||||||
|
The stream carries every query the server answers; these filters apply to history only.
|
||||||
|
</p>
|
||||||
|
<LiveActivity origin={search} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<HistoryActivity search={search} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Activity in history mode: the persisted queries the URL's filters select,
|
||||||
|
* paged by keyset cursor.
|
||||||
|
*
|
||||||
|
* The filters arrive already applied — the URL is the applied state — so this
|
||||||
|
* only reads them. Everything about how the reader got here lives one level up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import * as api from "@/lib/api";
|
||||||
|
import CoverageNotice from "@/lib/CoverageNotice";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||||
|
import type { QueryRow } from "@/lib/types";
|
||||||
|
import { useClientNames } from "@/features/clients/clientNames";
|
||||||
|
import { summarizeRow } from "@/features/provenance/querySummary";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||||
|
import { queriesFilterOf, type ActivitySearch } from "./search";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
empty: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
tableWrap: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||||
|
row: {
|
||||||
|
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||||
|
borderTopStyle: "solid",
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
refetching: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
},
|
||||||
|
moreButton: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
moreError: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryActivity({ search }: { search: ActivitySearch }) {
|
||||||
|
const filter = queriesFilterOf(search);
|
||||||
|
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
||||||
|
const clientNames = useClientNames();
|
||||||
|
|
||||||
|
const pages = base.data?.pages ?? [];
|
||||||
|
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||||
|
const coverage = pages[0]?.coverage;
|
||||||
|
const filterActive = Object.keys(filter).length > 0;
|
||||||
|
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||||
|
// data stands in for a filter change; derive the cursor from what is on
|
||||||
|
// screen so the button keeps its place instead of flashing "end of log".
|
||||||
|
const lastPage = pages[pages.length - 1];
|
||||||
|
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
|
||||||
|
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
||||||
|
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
|
||||||
|
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||||
|
void base.fetchNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The loader starts this fetch but does not wait for it, so both the first
|
||||||
|
// paint and a failed first page are this component's to render.
|
||||||
|
if (base.status === "error" && base.data === undefined) {
|
||||||
|
return <InlineError error={base.error} onRetry={() => void base.refetch()} />;
|
||||||
|
}
|
||||||
|
if (base.data === undefined) {
|
||||||
|
return (
|
||||||
|
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||||
|
Loading activity…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{base.isFetching && (
|
||||||
|
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p {...stylex.props(styles.empty)}>
|
||||||
|
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
|
<table {...stylex.props(styles.table)}>
|
||||||
|
<ActivityTableHead />
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||||
|
<ActivityCells
|
||||||
|
row={summarizeRow(row)}
|
||||||
|
clientNames={clientNames}
|
||||||
|
renderDomain={(id, children) =>
|
||||||
|
id === null ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/activity/queries/$id"
|
||||||
|
params={{ id: String(id) }}
|
||||||
|
search={search}
|
||||||
|
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div {...stylex.props(styles.footer)}>
|
||||||
|
<p {...stylex.props(styles.note)}>
|
||||||
|
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||||
|
{hasMore ? "" : " — end of log"}
|
||||||
|
</p>
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||||
|
{...stylex.props(shared.button, styles.moreButton, shared.focusRing)}
|
||||||
|
>
|
||||||
|
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{moreError !== null && (
|
||||||
|
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||||
|
Failed to load more: {moreError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
/**
|
||||||
|
* Activity in live mode, through the real router.
|
||||||
|
*
|
||||||
|
* The EventSource is a global here rather than an injected factory: whether the
|
||||||
|
* connection exists at all is the thing under test, and that is decided by
|
||||||
|
* which subtree the URL mounts, not by a prop a caller could pass.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
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/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 {
|
||||||
|
id: Number(ip.split(".").pop()),
|
||||||
|
ip,
|
||||||
|
name,
|
||||||
|
learned_name: learnedName,
|
||||||
|
group_id: 1,
|
||||||
|
group: "default",
|
||||||
|
hand_edited: name !== "",
|
||||||
|
first_seen: 1_700_000_000,
|
||||||
|
last_seen: 1_700_000_100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENTS: Client[] = [
|
||||||
|
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||||
|
client("192.0.2.11", "", "laptop.lan"),
|
||||||
|
client("192.0.2.12", "", ""),
|
||||||
|
];
|
||||||
|
|
||||||
|
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||||
|
|
||||||
|
let sources: FakeEventSource[];
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function json(payload: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
|
||||||
|
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||||
|
if (url === "/api/clients") return Promise.resolve(json({ clients: CLIENTS }));
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sources = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"EventSource",
|
||||||
|
class {
|
||||||
|
constructor(url: string) {
|
||||||
|
const source = new FakeEventSource(url);
|
||||||
|
sources.push(source);
|
||||||
|
return source as unknown as EventSource;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
stubFetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function frame(ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): { data: string } {
|
||||||
|
return {
|
||||||
|
data: JSON.stringify(
|
||||||
|
provenance({
|
||||||
|
...sections,
|
||||||
|
request: { time: ts, domain, ...sections.request },
|
||||||
|
route: { kind: "cache", upstream: "", ...sections.route },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(path = "/activity?mode=live") {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const history = createMemoryHistory({ initialEntries: [path] });
|
||||||
|
const router = createAppRouter(history, queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
return { history };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLive(path?: string) {
|
||||||
|
const rendered = renderPage(path);
|
||||||
|
await screen.findByRole("button", { name: "Freeze" });
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryCalls(): string[] {
|
||||||
|
return fetchMock.mock.calls
|
||||||
|
.map((call) => String(call[0]))
|
||||||
|
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("streams rows, flags blocked ones, and freezes the display", async () => {
|
||||||
|
await openLive();
|
||||||
|
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("query", frame(1000, "ok.example"));
|
||||||
|
sources[0]!.emit(
|
||||||
|
"query",
|
||||||
|
frame(1001, "ads.example", {
|
||||||
|
request: { qtype: 28 },
|
||||||
|
policy: { action: "block", reason: "blocklist_wildcard" },
|
||||||
|
route: { kind: "blocked" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("ok.example")).toBeTruthy();
|
||||||
|
expect(screen.getByText("AAAA")).toBeTruthy();
|
||||||
|
const blockedRow = screen.getByText("ads.example").closest("tr")!;
|
||||||
|
expect(within(blockedRow).getAllByText("Blocked")).toHaveLength(2);
|
||||||
|
// StyleX compiles to opaque class names, so the check is structural: a blocked
|
||||||
|
// row carries every class a plain row does, plus the ones the flag adds.
|
||||||
|
const plainRow = screen.getByText("ok.example").closest("tr")!;
|
||||||
|
const blockedClasses = new Set(blockedRow.className.split(" "));
|
||||||
|
const plainClasses = plainRow.className.split(" ");
|
||||||
|
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
|
||||||
|
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
|
||||||
|
|
||||||
|
const freeze = screen.getByRole("button", { name: "Freeze" });
|
||||||
|
fireEvent.click(freeze);
|
||||||
|
expect(freeze.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
|
||||||
|
act(() => sources[0]!.emit("query", frame(1002, "later.example")));
|
||||||
|
expect(screen.queryByText("later.example")).toBeNull();
|
||||||
|
expect(screen.getByText(/3 in buffer/)).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||||
|
expect(screen.getByText("later.example")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||||
|
await openLive();
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
|
||||||
|
sources[0]!.emit("query", frame(1001, "learned.example", { request: { client: "192.0.2.11" } }));
|
||||||
|
sources[0]!.emit("query", frame(1002, "nameless.example", { request: { client: "192.0.2.12" } }));
|
||||||
|
sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const named = await screen.findByText("Kitchen Pi");
|
||||||
|
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||||
|
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||||
|
|
||||||
|
const learned = screen.getByText("laptop.lan");
|
||||||
|
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||||
|
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||||
|
|
||||||
|
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||||
|
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rows stream in as bare IPs while the client list is still loading", async () => {
|
||||||
|
let releaseClients: () => void = () => {};
|
||||||
|
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||||
|
// 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({}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
releaseClients = () => resolve(json({ clients: CLIENTS }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await openLive();
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })));
|
||||||
|
|
||||||
|
expect(screen.getByText("192.0.2.10")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Kitchen Pi")).toBeNull();
|
||||||
|
|
||||||
|
releaseClients();
|
||||||
|
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated connection failures show the viewer-cap state with a retry button", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByRole("button", { name: "Freeze" });
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("error");
|
||||||
|
sources[0]!.emit("error");
|
||||||
|
sources[0]!.emit("error");
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a recovered row links to its stored detail; a streamed one opens in place instead", async () => {
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url.startsWith("/api/queries?")) {
|
||||||
|
return json({
|
||||||
|
queries: [queryRow(88, { ts: 1001, domain: "recovered.example" })],
|
||||||
|
next_before: null,
|
||||||
|
coverage: { complete: true, available_since: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return json({});
|
||||||
|
});
|
||||||
|
|
||||||
|
await openLive();
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
|
act(() => sources[0]!.emit("error"));
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
|
||||||
|
const recovered = await screen.findByRole("link", { name: "recovered.example" });
|
||||||
|
expect(recovered.getAttribute("href")).toContain("/activity/queries/88");
|
||||||
|
// The streamed frame precedes its own insert, so it has no row to link to —
|
||||||
|
// but it does carry its own provenance, so it still has a detail.
|
||||||
|
expect(screen.queryByRole("link", { name: "streamed.example" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a streamed row opens its own provenance, from the keyboard as well as the pointer", async () => {
|
||||||
|
await openLive();
|
||||||
|
act(() =>
|
||||||
|
sources[0]!.emit(
|
||||||
|
"query",
|
||||||
|
frame(1000, "streamed.example", {
|
||||||
|
policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" },
|
||||||
|
route: { kind: "blocked", upstream: "" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||||
|
// A real <button> is in the tab order and activates on Enter and Space; the
|
||||||
|
// only way to lose that is to opt out of it, which nothing here may do.
|
||||||
|
expect(trigger.tagName).toBe("BUTTON");
|
||||||
|
expect(trigger.getAttribute("tabindex")).toBeNull();
|
||||||
|
act(() => trigger.focus());
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
const heading = screen.getByRole("heading", { level: 1, name: "streamed.example" });
|
||||||
|
expect(heading).toBeTruthy();
|
||||||
|
const panel = heading.closest("div")!.parentElement!;
|
||||||
|
expect(within(panel).getByText("Blocked locally")).toBeTruthy();
|
||||||
|
expect(panel.textContent).toContain("the query log may not have written it yet");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
|
||||||
|
await openLive();
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
|
|
||||||
|
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||||
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||||
|
|
||||||
|
// A native button activates on Enter and Space; jsdom does not synthesize
|
||||||
|
// the click those keys fire, so the click is the activation.
|
||||||
|
act(() => trigger.focus());
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
|
||||||
|
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||||
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
|
||||||
|
// The panel is inserted above the table, behind the trigger in tab order, so
|
||||||
|
// the only thing that keeps a forward tab inside it is focus moving in.
|
||||||
|
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(document.activeElement).toBe(panel);
|
||||||
|
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening a second row moves the expanded state and the focus with it", async () => {
|
||||||
|
await openLive();
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("query", frame(1000, "first.example"));
|
||||||
|
sources[0]!.emit("query", frame(1001, "second.example"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = screen.getByRole("button", { name: "first.example" });
|
||||||
|
const second = screen.getByRole("button", { name: "second.example" });
|
||||||
|
fireEvent.click(first);
|
||||||
|
fireEvent.click(second);
|
||||||
|
|
||||||
|
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||||
|
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
|
||||||
|
expect(document.activeElement).toBe(panel);
|
||||||
|
expect(first.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(second.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(document.activeElement).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an open streamed detail survives the row being evicted from the ring buffer", () => {
|
||||||
|
renderLiveWithCapacity(5);
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
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();
|
||||||
|
|
||||||
|
// One ringful more: the ring keeps the newest 5, so the selected row is gone
|
||||||
|
// from the table. The detail is a snapshot, not a lookup into the ring.
|
||||||
|
act(() => {
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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("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" }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
||||||
|
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||||
|
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
|
||||||
|
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
||||||
|
|
||||||
|
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
|
||||||
|
expect(domain.value).toBe("ads");
|
||||||
|
expect(domain.disabled).toBe(true);
|
||||||
|
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
|
||||||
|
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
|
||||||
|
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
|
||||||
|
|
||||||
|
const form = domain.closest("form")!;
|
||||||
|
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
|
||||||
|
expect(controls.length).toBeGreaterThan(0);
|
||||||
|
for (const control of controls) {
|
||||||
|
// A disabled form control is skipped by the browser's tab order, and RAC
|
||||||
|
// pins its own trigger out of it as well. Nothing in the row may
|
||||||
|
// reintroduce itself with a reachable tabindex.
|
||||||
|
expect(control.hasAttribute("disabled")).toBe(true);
|
||||||
|
const tabindex = control.getAttribute("tabindex");
|
||||||
|
expect(tabindex === null || tabindex === "-1").toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
||||||
|
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true&since=1700000000&bogus=1");
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
|
|
||||||
|
expect(queryCalls()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
||||||
|
await openLive("/activity?mode=live&domain=ads");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "History" }));
|
||||||
|
await screen.findByRole("button", { name: "Apply filters" });
|
||||||
|
expect(sources).toHaveLength(1);
|
||||||
|
expect(sources[0]!.closed).toBe(true);
|
||||||
|
// The filters came along, which is the point of switching rather than
|
||||||
|
// navigating: the reader keeps the question they were asking.
|
||||||
|
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
||||||
|
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Live" }));
|
||||||
|
await screen.findByRole("button", { name: "Freeze" });
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
expect(sources[1]!.closed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The related-actions region of a query detail. Scoped on purpose: the sidebar
|
||||||
|
* carries a Pause of its own, and this is the one that answers "this query was
|
||||||
|
* blocked and should not have been".
|
||||||
|
*/
|
||||||
|
function related(): HTMLElement {
|
||||||
|
return screen.getByRole("region", { name: "Related" });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => {
|
||||||
|
await openLive();
|
||||||
|
act(() =>
|
||||||
|
sources[0]!.emit(
|
||||||
|
"query",
|
||||||
|
frame(1000, "streamed.example", {
|
||||||
|
policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" },
|
||||||
|
route: { kind: "blocked", upstream: "" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a streamed row that was allowed offers nothing to pause", async () => {
|
||||||
|
await openLive();
|
||||||
|
act(() => sources[0]!.emit("query", frame(1001, "allowed.example", { policy: { action: "allow" } })));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "allowed.example" }));
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { level: 1, name: "allowed.example" });
|
||||||
|
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
||||||
|
});
|
||||||
+198
-25
@@ -1,25 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
|
||||||
|
* in-place detail a streamed row opens.
|
||||||
|
*
|
||||||
|
* This subtree is mounted only while the URL says `mode=live`, which is what
|
||||||
|
* closes the EventSource on the way back to history: the connection is a
|
||||||
|
* server-side resource capped per address, so a page that kept it open while
|
||||||
|
* showing something else would spend a viewer slot on nothing.
|
||||||
|
*
|
||||||
|
* Freeze and Follow are display state and stay out of the URL. They describe
|
||||||
|
* what the screen is doing right now, not what it is showing, so a shared link
|
||||||
|
* would carry a frozen moment the recipient never saw fill.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { useClientNames } from "@/features/clients/clientNames";
|
import { useClientNames } from "@/features/clients/clientNames";
|
||||||
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
import { summarizeEvent } from "@/features/provenance/querySummary";
|
||||||
import { RING_CAPACITY } from "./ringBuffer";
|
|
||||||
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||||
|
import ProvenanceDetail from "./ProvenanceDetail";
|
||||||
|
import RelatedActions from "./RelatedActions";
|
||||||
|
import { RING_CAPACITY, summaryOf, type StreamedRow } from "./ringBuffer";
|
||||||
|
import type { ActivitySearch } from "./search";
|
||||||
|
import { useLiveQueries, type StreamStatus } from "./useLiveQueries";
|
||||||
|
|
||||||
const DARK = "@media (prefers-color-scheme: dark)";
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
toolbar: {
|
toolbar: {
|
||||||
|
marginTop: "1rem",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
},
|
},
|
||||||
heading: {
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
lineHeight: "2rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
toolbarButton: {
|
toolbarButton: {
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
@@ -134,6 +150,40 @@ const styles = stylex.create({
|
|||||||
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
|
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/** A streamed row opens its detail here rather than at a route, so it is a button that looks like the link beside it. */
|
||||||
|
domainButton: {
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
font: "inherit",
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
textDecorationStyle: "dotted",
|
||||||
|
},
|
||||||
|
detailPanel: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
detailBar: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
detailLabel: {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
footnote: {
|
footnote: {
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
@@ -142,6 +192,10 @@ 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> = {
|
const PILL_LABELS: Record<StreamStatus, string> = {
|
||||||
connecting: "Connecting…",
|
connecting: "Connecting…",
|
||||||
open: "Live",
|
open: "Live",
|
||||||
@@ -165,16 +219,97 @@ function StatusPill({ status }: { status: StreamStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Freeze is display-only: the stream stays open and the 500-row ring buffer keeps
|
/**
|
||||||
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
|
* The open detail, held as the selected row itself rather than as a key into
|
||||||
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
* the buffer.
|
||||||
const live = useLiveQueries({ createEventSource });
|
*
|
||||||
const clientNames = useClientNames();
|
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
||||||
|
* key would go stale under the reader while they were still reading it, and the
|
||||||
|
* panel would blank out for no reason they could see. The snapshot is the whole
|
||||||
|
* fact — a streamed frame carries its own provenance — so it survives eviction,
|
||||||
|
* a merge and a Freeze/Resume, and closes only when the reader closes it or
|
||||||
|
* leaves live mode.
|
||||||
|
*/
|
||||||
|
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
||||||
|
const summary = summarizeEvent(row.event);
|
||||||
|
const panel = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// The panel opens above the table, behind the trigger in tab order, so a
|
||||||
|
// forward tab from the row would walk past it. Focus moves in on open —
|
||||||
|
// keyed on the row, so choosing a second row moves it again — and the
|
||||||
|
// closer puts it back on the trigger.
|
||||||
|
useEffect(() => {
|
||||||
|
panel.current?.focus();
|
||||||
|
}, [row.key]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<div
|
||||||
|
ref={panel}
|
||||||
|
id={DETAIL_PANEL_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
role="group"
|
||||||
|
aria-labelledby={DETAIL_LABEL_ID}
|
||||||
|
{...stylex.props(styles.detailPanel)}
|
||||||
|
>
|
||||||
|
<div {...stylex.props(styles.detailBar)}>
|
||||||
|
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
|
||||||
|
Streamed query
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ProvenanceDetail
|
||||||
|
provenance={row.event}
|
||||||
|
persistedId={null}
|
||||||
|
relatedActions={
|
||||||
|
<RelatedActions
|
||||||
|
domain={summary.domain}
|
||||||
|
client={summary.client_ip}
|
||||||
|
ts={summary.ts}
|
||||||
|
origin={origin}
|
||||||
|
blocked={row.event.policy.action === "block"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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);
|
||||||
|
|
||||||
|
function open(row: StreamedRow, from: HTMLButtonElement) {
|
||||||
|
trigger.current = from;
|
||||||
|
setSelected(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row that opened the panel takes focus back, unless the ring has
|
||||||
|
// already evicted it: a detached button cannot be focused, and the browser
|
||||||
|
// falls back to the document, which is the best available answer.
|
||||||
|
function close() {
|
||||||
|
setSelected(null);
|
||||||
|
trigger.current?.focus();
|
||||||
|
trigger.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<div {...stylex.props(styles.toolbar)}>
|
<div {...stylex.props(styles.toolbar)}>
|
||||||
<h1 {...stylex.props(styles.heading)}>Live</h1>
|
|
||||||
<StatusPill status={live.status} />
|
<StatusPill status={live.status} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -188,8 +323,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
|||||||
|
|
||||||
{live.frozen && (
|
{live.frozen && (
|
||||||
<p {...stylex.props(styles.note)} role="status">
|
<p {...stylex.props(styles.note)} role="status">
|
||||||
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
|
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept).
|
||||||
kept).
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -228,6 +362,8 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
|
||||||
|
|
||||||
{live.rows.length === 0 ? (
|
{live.rows.length === 0 ? (
|
||||||
live.status !== "capped" && (
|
live.status !== "capped" && (
|
||||||
<p {...stylex.props(styles.empty)}>
|
<p {...stylex.props(styles.empty)}>
|
||||||
@@ -238,22 +374,59 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
|||||||
<>
|
<>
|
||||||
<div {...stylex.props(styles.tableWrap)}>
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
<table {...stylex.props(styles.table)}>
|
<table {...stylex.props(styles.table)}>
|
||||||
<QueryTableHead />
|
<ActivityTableHead />
|
||||||
<tbody>
|
<tbody>
|
||||||
{live.rows.map((row) => (
|
{live.rows.map((row) => {
|
||||||
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
|
const summary = summaryOf(row);
|
||||||
<QueryCells row={row} clientNames={clientNames} />
|
return (
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p {...stylex.props(styles.footnote)}>
|
<p {...stylex.props(styles.footnote)}>
|
||||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
||||||
{RING_CAPACITY} kept).
|
{capacity} kept).
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
import type { LookupResult } from "@/lib/types";
|
||||||
|
|
||||||
|
const BLOCKED: LookupResult = {
|
||||||
|
domain: "ads.example",
|
||||||
|
group_id: 1,
|
||||||
|
local_records: false,
|
||||||
|
forward_zone: null,
|
||||||
|
blocked: true,
|
||||||
|
reason: "blocklist_domain",
|
||||||
|
matched: "ads.example",
|
||||||
|
source_url: "https://lists.test/a",
|
||||||
|
safe_search_rewrite: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof createFetchMock>;
|
||||||
|
/** What `/api/lookup` answers, so a test can make it fail without rebuilding the mock. */
|
||||||
|
let lookup: (url: string) => Response;
|
||||||
|
|
||||||
|
function json(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFetchMock() {
|
||||||
|
return vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/groups") {
|
||||||
|
return json({
|
||||||
|
groups: [
|
||||||
|
{ id: 1, name: "default", safe_search: false },
|
||||||
|
{ id: 2, name: "kids", safe_search: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.startsWith("/api/lookup")) return lookup(url);
|
||||||
|
return json({ error: "not stubbed" }, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
lookup = (url) =>
|
||||||
|
url === "/api/lookup?domain=ads.example&group_id=1" ? json(BLOCKED) : json({ error: "not stubbed" }, 404);
|
||||||
|
fetchMock = createFetchMock();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `retry: false` for the failure tests: the shared client retries a 5xx twice
|
||||||
|
* and a 429 after its Retry-After, so the surfaced error is what the page does
|
||||||
|
* once the client has given up, not something a test should sit out in real
|
||||||
|
* time.
|
||||||
|
*/
|
||||||
|
function renderPage(path = "/activity/test", { retry = true } = {}) {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
if (!retry) {
|
||||||
|
const defaults = queryClient.getDefaultOptions();
|
||||||
|
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
|
||||||
|
}
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupCalls(): string[] {
|
||||||
|
return fetchMock.mock.calls.map(([input]) => String(input)).filter((url) => url.startsWith("/api/lookup"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("fetches nothing until submit, then renders the blocked verdict", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Current policy simulation" });
|
||||||
|
await screen.findByLabelText("Group");
|
||||||
|
expect(lookupCalls()).toEqual([]);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||||
|
expect(lookupCalls()).toEqual([]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Blocked" });
|
||||||
|
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||||
|
|
||||||
|
expect(screen.getByText("blocklist_domain")).toBeTruthy();
|
||||||
|
const link = screen.getByRole("link", { name: "https://lists.test/a" }) as HTMLAnchorElement;
|
||||||
|
expect(link.href).toBe("https://lists.test/a");
|
||||||
|
expect(screen.getByText("Queries for this name get a blocked response.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a ?domain= link asks the question on arrival instead of leaving a filled-in form", async () => {
|
||||||
|
renderPage("/activity/test?domain=ads.example");
|
||||||
|
|
||||||
|
// No submit here: the link is the question, so the verdict is what arrives.
|
||||||
|
await screen.findByRole("heading", { name: "Blocked" });
|
||||||
|
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||||
|
expect(screen.getByLabelText("Domain")).toHaveProperty("value", "ads.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no filter snapshot reads as a server that is starting, not as a verdict", async () => {
|
||||||
|
lookup = () => json({ error: "no snapshot" }, 503);
|
||||||
|
renderPage("/activity/test", { retry: false });
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toContain("No filter snapshot is loaded yet");
|
||||||
|
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||||
|
// Nothing may read as an answer while the lookup has none.
|
||||||
|
expect(screen.queryByRole("heading", { name: "Blocked" })).toBeNull();
|
||||||
|
expect(screen.queryByText("Simulating…")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rate limit says how long to wait, from the server's own Retry-After", async () => {
|
||||||
|
lookup = () => json({ error: "rate limited" }, 429, { "retry-after": "12" });
|
||||||
|
renderPage("/activity/test", { retry: false });
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toBe("Rate limited. Try again in 12s.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resubmitting the same domain and group refetches rather than showing a stale verdict", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||||
|
await screen.findByRole("heading", { name: "Blocked" });
|
||||||
|
expect(lookupCalls()).toHaveLength(1);
|
||||||
|
|
||||||
|
// The policy can change between two identical questions, so the second one
|
||||||
|
// has to reach the server even though the query key has not moved.
|
||||||
|
lookup = () => json({ ...BLOCKED, blocked: false, reason: "no_match", matched: "", source_url: null });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Allowed" });
|
||||||
|
expect(lookupCalls()).toEqual([
|
||||||
|
"/api/lookup?domain=ads.example&group_id=1",
|
||||||
|
"/api/lookup?domain=ads.example&group_id=1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults the group select to the default group (id 1)", async () => {
|
||||||
|
renderPage();
|
||||||
|
// A RAC Select names its trigger with the current value and then the label, so
|
||||||
|
// the selected group's name is the only thing the trigger shows.
|
||||||
|
const trigger = await screen.findByRole("button", { name: /Group$/ });
|
||||||
|
expect(trigger.textContent).toContain("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the framing is forward-tense, so it cannot be read as an account of a past query", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByRole("heading", { name: "Current policy simulation" });
|
||||||
|
|
||||||
|
const intro = screen.getByRole("heading", { name: "Current policy simulation" }).nextElementSibling;
|
||||||
|
expect(intro?.textContent).toContain("would");
|
||||||
|
expect(intro?.textContent).toContain("right now");
|
||||||
|
// Nothing on the page may claim to explain a query that already happened.
|
||||||
|
expect(document.body.textContent).not.toContain("Look up");
|
||||||
|
});
|
||||||
+17
-9
@@ -1,5 +1,6 @@
|
|||||||
import { useState, type FormEvent, type ReactNode } from "react";
|
import { useState, type FormEvent, type ReactNode } from "react";
|
||||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { useSearch } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
||||||
@@ -249,13 +250,18 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LookupPage() {
|
export default function PolicyTestPage() {
|
||||||
const groups = useSuspenseQuery(groupsQuery()).data;
|
const groups = useSuspenseQuery(groupsQuery()).data;
|
||||||
const preselectedGroupId = defaultGroupId(groups);
|
const preselectedGroupId = defaultGroupId(groups);
|
||||||
|
|
||||||
const [domain, setDomain] = useState("");
|
// A `?domain=` link (from a query's detail page) arrives already asking the
|
||||||
|
// question, so it runs the simulation rather than leaving a filled-in form.
|
||||||
|
const search = useSearch({ from: "/shell/activity/test" });
|
||||||
|
const [domain, setDomain] = useState(search.domain ?? "");
|
||||||
const [groupId, setGroupId] = useState(preselectedGroupId);
|
const [groupId, setGroupId] = useState(preselectedGroupId);
|
||||||
const [submitted, setSubmitted] = useState<Submitted | null>(null);
|
const [submitted, setSubmitted] = useState<Submitted | null>(
|
||||||
|
search.domain === undefined ? null : { domain: search.domain, groupId: preselectedGroupId },
|
||||||
|
);
|
||||||
|
|
||||||
const lookup = useQuery({
|
const lookup = useQuery({
|
||||||
...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
|
...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
|
||||||
@@ -275,17 +281,19 @@ export default function LookupPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
|
<h1 {...stylex.props(styles.heading)}>Current policy simulation</h1>
|
||||||
<p {...stylex.props(styles.intro)}>
|
<p {...stylex.props(styles.intro)}>
|
||||||
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
What the pipeline <em>would</em> do with a domain right now: local records, forward zones, block
|
||||||
|
decision, safe search. This reads the configuration in force at this moment, so it explains nothing
|
||||||
|
about a query already answered — a detail page does that.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||||
<div {...stylex.props(styles.domainField)}>
|
<div {...stylex.props(styles.domainField)}>
|
||||||
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
|
<label htmlFor="policy-test-domain" {...stylex.props(styles.fieldLabel)}>
|
||||||
Domain
|
Domain
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="lookup-domain"
|
id="policy-test-domain"
|
||||||
required
|
required
|
||||||
value={domain}
|
value={domain}
|
||||||
onChange={(event) => setDomain(event.target.value)}
|
onChange={(event) => setDomain(event.target.value)}
|
||||||
@@ -306,10 +314,10 @@ export default function LookupPage() {
|
|||||||
disabled={lookup.isFetching}
|
disabled={lookup.isFetching}
|
||||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Look up
|
Simulate
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up…</p>}
|
{lookup.isFetching && <p {...stylex.props(styles.note)}>Simulating…</p>}
|
||||||
{!lookup.isFetching && lookup.isError && (
|
{!lookup.isFetching && lookup.isError && (
|
||||||
<p role="alert" {...stylex.props(styles.error)}>
|
<p role="alert" {...stylex.props(styles.error)}>
|
||||||
{errorMessage(lookup.error)}
|
{errorMessage(lookup.error)}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* One query, explained in the order it met the pipeline.
|
||||||
|
*
|
||||||
|
* This is the body of the detail surface, with no route in it, because two
|
||||||
|
* surfaces show it: the persisted detail page, which fetched the row by id, and
|
||||||
|
* a live row, whose provenance arrived in the stream frame and which SQLite may
|
||||||
|
* not have written yet. The related actions are links into routes, so the
|
||||||
|
* caller passes them in already built.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { formatMicros, formatTime } from "@/lib/format";
|
||||||
|
import type { PolicyReason, Provenance } from "@/lib/types";
|
||||||
|
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||||
|
import {
|
||||||
|
policyActionLabel,
|
||||||
|
policyReasonLabel,
|
||||||
|
qclassName,
|
||||||
|
rcodeName,
|
||||||
|
routeKindLabel,
|
||||||
|
} from "@/features/provenance/provenanceCopy";
|
||||||
|
import { qtypeName } from "@/features/provenance/qtype";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
wordBreak: "break-all",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
/** The recorded facts, fenced off from the live links below them. */
|
||||||
|
record: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
maxWidth: "48rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
recordNote: {
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
borderTopWidth: { default: 1, ":first-of-type": 0 },
|
||||||
|
borderTopStyle: "solid",
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
paddingTop: { default: "1rem", ":first-of-type": 0 },
|
||||||
|
},
|
||||||
|
sectionHeading: {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
facts: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
marginBottom: 0,
|
||||||
|
display: "grid",
|
||||||
|
gap: "0.375rem 1rem",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: "auto",
|
||||||
|
"@media (min-width: 640px)": "max-content 1fr",
|
||||||
|
},
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
term: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
margin: 0,
|
||||||
|
wordBreak: "break-all",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
related: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
maxWidth: "48rem",
|
||||||
|
},
|
||||||
|
relatedHeading: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
relatedNote: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
relatedList: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The look of one related action, so every caller's links match. */
|
||||||
|
export const provenanceRelatedLink = styles.link;
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div {...stylex.props(styles.section)}>
|
||||||
|
<h2 {...stylex.props(styles.sectionHeading)}>{title}</h2>
|
||||||
|
<dl {...stylex.props(styles.facts)}>{children}</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fact({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dt {...stylex.props(styles.term)}>{label}</dt>
|
||||||
|
<dd {...stylex.props(styles.value)}>{children}</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recorded name, in the face the rest of the interface gives to names. It
|
||||||
|
* wraps the value rather than the row, so the prose that stands in for a
|
||||||
|
* missing one is not set in the same typewriter face.
|
||||||
|
*/
|
||||||
|
function Mono({ children }: { children: string }) {
|
||||||
|
return <span {...stylex.props(shared.mono)}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty text field means the server recorded nothing there, never an empty value. */
|
||||||
|
function Absent({ children }: { children: string }) {
|
||||||
|
return <span {...stylex.props(styles.muted)}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an empty `matched` means. `no_match` is the only reason the matcher
|
||||||
|
* itself records with nothing to show; every other empty one names a pipeline
|
||||||
|
* step that answered before filtering — a local record, a forward zone, a pause
|
||||||
|
* (see `PolicyReason` in src/storage/provenance.zig) — where "nothing matched"
|
||||||
|
* would claim an evaluation that never happened.
|
||||||
|
*/
|
||||||
|
function unmatchedLabel(reason: PolicyReason): string {
|
||||||
|
return reason === "no_match" ? "Nothing matched" : "The matcher never ran";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
provenance: Provenance;
|
||||||
|
/**
|
||||||
|
* The log row this explains, or null for a frame read straight off the
|
||||||
|
* stream. Null is the same fact `QuerySummary.id` carries: the event
|
||||||
|
* precedes its own insert, so there is no row to name and none is invented.
|
||||||
|
*/
|
||||||
|
persistedId: number | null;
|
||||||
|
/** The related-action links, built by whichever surface owns the routes. */
|
||||||
|
relatedActions: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) {
|
||||||
|
const { request, group, policy, rewrites, route, response } = provenance;
|
||||||
|
const clientNames = useClientNames();
|
||||||
|
const currentClient = clientLabel(request.client, clientNames);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 {...stylex.props(styles.heading, shared.mono)}>{request.domain}</h1>
|
||||||
|
<p {...stylex.props(styles.subtitle)}>
|
||||||
|
{formatTime(request.time)} — {policyActionLabel(policy.action)}
|
||||||
|
{/* The verdict alone reads as a success; a non-NOERROR answer says otherwise. */}
|
||||||
|
{response.rcode !== 0 && ` — ${rcodeName(response.rcode)}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div {...stylex.props(styles.record)}>
|
||||||
|
<p {...stylex.props(styles.recordNote)}>
|
||||||
|
What was recorded when this query was answered. Group and blocklist names are the ones in force at
|
||||||
|
that moment; they may have been renamed or deleted since.
|
||||||
|
{persistedId === null &&
|
||||||
|
" This is the event as it was streamed; the query log may not have written it yet."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Section title="Request">
|
||||||
|
<Fact label="Time">{formatTime(request.time)}</Fact>
|
||||||
|
<Fact label="Domain">
|
||||||
|
<Mono>{request.domain}</Mono>
|
||||||
|
</Fact>
|
||||||
|
<Fact label="Client">
|
||||||
|
<Mono>{request.client}</Mono>
|
||||||
|
</Fact>
|
||||||
|
<Fact label="Type">{qtypeName(request.qtype)}</Fact>
|
||||||
|
<Fact label="Class">{qclassName(request.qclass)}</Fact>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Group">
|
||||||
|
<Fact label="Name">{group.name === "" ? <Absent>No group recorded</Absent> : group.name}</Fact>
|
||||||
|
<Fact label="Id">{group.id === null ? <Absent>—</Absent> : group.id}</Fact>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Policy">
|
||||||
|
<Fact label="Decision">{policyActionLabel(policy.action)}</Fact>
|
||||||
|
<Fact label="Reason">{policyReasonLabel(policy.reason)}</Fact>
|
||||||
|
<Fact label="Matched">
|
||||||
|
{policy.matched === "" ? (
|
||||||
|
<Absent>{unmatchedLabel(policy.reason)}</Absent>
|
||||||
|
) : (
|
||||||
|
<Mono>{policy.matched}</Mono>
|
||||||
|
)}
|
||||||
|
</Fact>
|
||||||
|
<Fact label="Blocklist">
|
||||||
|
{policy.source_name === "" ? (
|
||||||
|
<Absent>Not a blocklist decision</Absent>
|
||||||
|
) : policy.source_id === null ? (
|
||||||
|
policy.source_name
|
||||||
|
) : (
|
||||||
|
`${policy.source_name} (#${policy.source_id})`
|
||||||
|
)}
|
||||||
|
</Fact>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Rewrites">
|
||||||
|
<Fact label="CNAME target">
|
||||||
|
{rewrites.cname_target === "" ? (
|
||||||
|
<Absent>The queried name was decided directly</Absent>
|
||||||
|
) : (
|
||||||
|
<Mono>{rewrites.cname_target}</Mono>
|
||||||
|
)}
|
||||||
|
</Fact>
|
||||||
|
<Fact label="Safe search">
|
||||||
|
{rewrites.safe_search_target === "" ? (
|
||||||
|
<Absent>No rewrite</Absent>
|
||||||
|
) : (
|
||||||
|
<Mono>{rewrites.safe_search_target}</Mono>
|
||||||
|
)}
|
||||||
|
</Fact>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Route">
|
||||||
|
<Fact label="Answered by">{routeKindLabel(route.kind)}</Fact>
|
||||||
|
<Fact label="Forward zone">
|
||||||
|
{route.forward_zone === "" ? <Absent>—</Absent> : <Mono>{route.forward_zone}</Mono>}
|
||||||
|
</Fact>
|
||||||
|
<Fact label="Upstream">
|
||||||
|
{route.upstream === "" ? <Absent>No upstream exchange</Absent> : <Mono>{route.upstream}</Mono>}
|
||||||
|
</Fact>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Response">
|
||||||
|
<Fact label="Result">{rcodeName(response.rcode)}</Fact>
|
||||||
|
<Fact label="Took">
|
||||||
|
{response.duration_us === null ? (
|
||||||
|
<Absent>Not measured</Absent>
|
||||||
|
) : (
|
||||||
|
formatMicros(response.duration_us)
|
||||||
|
)}
|
||||||
|
</Fact>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
{currentClient !== null && (
|
||||||
|
<p {...stylex.props(styles.relatedNote)}>
|
||||||
|
{currentClient.learned ? (
|
||||||
|
<>
|
||||||
|
Reverse DNS currently resolves <Mono>{request.client}</Mono> to{" "}
|
||||||
|
<Mono>{currentClient.text}</Mono>.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
The client list currently names <Mono>{request.client}</Mono> “{currentClient.text}”.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* The links out of one query's detail, shared by the persisted detail page and
|
||||||
|
* the in-place detail a streamed row opens.
|
||||||
|
*
|
||||||
|
* Both surfaces answer the same four follow-up questions, and both are read
|
||||||
|
* from an investigation that has a time range. Every link therefore carries
|
||||||
|
* absolute bounds: a link that said "recently" would show a different set of
|
||||||
|
* queries every time it was opened, which is the opposite of what linking to an
|
||||||
|
* incident is for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import PauseControl from "@/features/pause/PauseControl";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { provenanceRelatedLink } from "./ProvenanceDetail";
|
||||||
|
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
|
||||||
|
import type { ActivitySearch } from "./search";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
domain: string;
|
||||||
|
client: string;
|
||||||
|
/** The second this query was answered, which every window is centred on. */
|
||||||
|
ts: number;
|
||||||
|
/** The Activity search the reader came from; its bounds win over the defaults. */
|
||||||
|
origin: Pick<ActivitySearch, "since" | "until">;
|
||||||
|
/**
|
||||||
|
* This query was blocked. Pausing is a valid answer to a block the reader
|
||||||
|
* disagrees with, and to nothing else here — so the control appears for a
|
||||||
|
* block and not beside an allowed query it could not have caused.
|
||||||
|
*/
|
||||||
|
blocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) {
|
||||||
|
const bounds = relatedBounds(ts, origin);
|
||||||
|
const window = diagnosticsBounds(ts);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to="/activity/test" search={{ domain }} {...stylex.props(provenanceRelatedLink, shared.focusRing)}>
|
||||||
|
Test this domain against current policy
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/activity"
|
||||||
|
search={{ mode: "history", domain, client: undefined, blocked: undefined, ...bounds }}
|
||||||
|
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
|
||||||
|
>
|
||||||
|
All activity for this domain
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/activity"
|
||||||
|
search={{ mode: "history", client, domain: undefined, blocked: undefined, ...bounds }}
|
||||||
|
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
|
||||||
|
>
|
||||||
|
All activity from this client
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/diagnostics"
|
||||||
|
search={{ since: window.since, until: window.until }}
|
||||||
|
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Diagnostics around this query
|
||||||
|
</Link>
|
||||||
|
{blocked && <PauseControl />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type { QueryRow } from "@/lib/types";
|
||||||
|
import type { ClientNames } from "@/features/clients/clientNames";
|
||||||
|
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||||
|
import { summarizeRow, type QuerySummary } from "@/features/provenance/querySummary";
|
||||||
|
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
|
||||||
|
|
||||||
|
const noNames: ClientNames = new Map();
|
||||||
|
|
||||||
|
function renderRow(overrides: Partial<QueryRow> = {}): HTMLTableRowElement {
|
||||||
|
const row: QuerySummary = summarizeRow(queryRow(1, overrides));
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<ActivityTableHead />
|
||||||
|
<tbody>
|
||||||
|
<tr data-testid="row">
|
||||||
|
<ActivityCells
|
||||||
|
row={row}
|
||||||
|
clientNames={noNames}
|
||||||
|
renderDomain={(id, children) => <a href={`/activity/queries/${id}`}>{children}</a>}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
return screen.getByTestId("row") as HTMLTableRowElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cell under a header, read by its column name rather than its index. */
|
||||||
|
function cell(row: HTMLTableRowElement, column: (typeof ACTIVITY_COLUMNS)[number]): string {
|
||||||
|
const index = ACTIVITY_COLUMNS.indexOf(column);
|
||||||
|
return row.cells[index]?.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the head names the seven columns in order", () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<ActivityTableHead />
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
const headers = screen.getAllByRole("columnheader").map((header) => header.textContent);
|
||||||
|
expect(headers).toEqual(["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an allowed NOERROR row reads as the answer it got, with no badge", () => {
|
||||||
|
const row = renderRow({ blocked: false, rcode: 0, route_kind: "upstream", response_time_us: 1234 });
|
||||||
|
expect(cell(row, "Result")).toBe("NOERROR");
|
||||||
|
expect(cell(row, "Route")).toBe("Upstream");
|
||||||
|
expect(cell(row, "Duration")).toBe("1.2 ms");
|
||||||
|
expect(cell(row, "Type")).toBe("A");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a blocked row reads Blocked even though the client got NOERROR", () => {
|
||||||
|
const row = renderRow({ blocked: true, rcode: 0, route_kind: "blocked", policy_reason: "blocklist_domain" });
|
||||||
|
expect(cell(row, "Result")).toBe("Blocked");
|
||||||
|
expect(cell(row, "Route")).toBe("Blocked");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a SERVFAIL row names the code", () => {
|
||||||
|
const row = renderRow({ blocked: false, rcode: 2, route_kind: "upstream", response_time_us: null });
|
||||||
|
expect(cell(row, "Result")).toBe("SERVFAIL");
|
||||||
|
expect(cell(row, "Duration")).toBe("—");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a cache hit names the cache as the route", () => {
|
||||||
|
const row = renderRow({ blocked: false, rcode: 0, route_kind: "cache", cache_hit: true, upstream: "" });
|
||||||
|
expect(cell(row, "Route")).toBe("Cache");
|
||||||
|
expect(cell(row, "Result")).toBe("NOERROR");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unassigned extended rcode keeps the numeric fallback, without the long form's parentheses", () => {
|
||||||
|
const row = renderRow({ blocked: false, rcode: 3841 });
|
||||||
|
expect(cell(row, "Result")).toBe("RCODE 3841");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a persisted row links its domain to the detail the caller chose", () => {
|
||||||
|
renderRow({ domain: "ads.example" });
|
||||||
|
expect(screen.getByRole("link", { name: "ads.example" }).getAttribute("href")).toBe("/activity/queries/1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a streamed row reaches the renderer with a null id, and can render as plain text", () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr data-testid="row">
|
||||||
|
<ActivityCells
|
||||||
|
row={{ ...summarizeRow(queryRow(1)), id: null }}
|
||||||
|
clientNames={noNames}
|
||||||
|
renderDomain={(id, children) => {
|
||||||
|
expect(id).toBeNull();
|
||||||
|
return children;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("link")).toBeNull();
|
||||||
|
expect(screen.getByTestId("row").textContent).toContain("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every route kind has a compact label", () => {
|
||||||
|
expect(routeLabel("blocked")).toBe("Blocked");
|
||||||
|
expect(routeLabel("local")).toBe("Local");
|
||||||
|
expect(routeLabel("forward_zone")).toBe("Forward zone");
|
||||||
|
expect(routeLabel("upstream")).toBe("Upstream");
|
||||||
|
expect(routeLabel("cache")).toBe("Cache");
|
||||||
|
expect(routeLabel("rejected")).toBe("Rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resultLabel is the pure form of the Result cell", () => {
|
||||||
|
expect(resultLabel({ blocked: true, rcode: 2 })).toBe("Blocked");
|
||||||
|
expect(resultLabel({ blocked: false, rcode: 5 })).toBe("REFUSED");
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* The seven columns of the Activity table: Time, Domain, Client, Type, Result,
|
||||||
|
* Route and Duration.
|
||||||
|
*
|
||||||
|
* The labels here are the compact forms a scanned table needs. The detail page
|
||||||
|
* keeps `provenanceCopy`'s long forms, which spell out the same facts with room
|
||||||
|
* for the rcode number and the "answered by" phrasing.
|
||||||
|
*
|
||||||
|
* The cells render both a stored row and a streamed event, so they know nothing
|
||||||
|
* about routes: the caller renders the Domain cell's contents and decides what,
|
||||||
|
* if anything, a row opens. A streamed row has no id — the frame precedes its
|
||||||
|
* own insert — and only the caller knows whether it has a surface for one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { formatMicros, formatTime } from "@/lib/format";
|
||||||
|
import type { RouteKind } from "@/lib/types";
|
||||||
|
import { ClientName, type ClientNames } from "@/features/clients/clientNames";
|
||||||
|
import { rcodeShortName } from "@/features/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";
|
||||||
|
|
||||||
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
head: {
|
||||||
|
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
nowrap: {
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
breakAll: {
|
||||||
|
wordBreak: "break-all",
|
||||||
|
},
|
||||||
|
small: {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
domainLink: {
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
textDecorationLine: "none",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The badge shape and its weight are the signal; the tint only says which
|
||||||
|
* kind of unhappy answer this was. A monochrome or colour-blind reading of
|
||||||
|
* the table still separates a blocked or failed row from a plain NOERROR
|
||||||
|
* one, which a hue alone would not.
|
||||||
|
*/
|
||||||
|
badge: {
|
||||||
|
display: "inline-block",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
paddingInline: "0.375rem",
|
||||||
|
paddingBlock: "0.125rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
blockedBadge: {
|
||||||
|
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||||
|
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||||
|
},
|
||||||
|
faultBadge: {
|
||||||
|
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
|
||||||
|
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(90.1% 0.076 70.697)" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ROUTE_LABELS: Record<RouteKind, string> = {
|
||||||
|
blocked: "Blocked",
|
||||||
|
local: "Local",
|
||||||
|
forward_zone: "Forward zone",
|
||||||
|
upstream: "Upstream",
|
||||||
|
cache: "Cache",
|
||||||
|
rejected: "Rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The compact Route label. `Record` over the union, so a new kind fails `tsc`. */
|
||||||
|
export function routeLabel(kind: RouteKind): string {
|
||||||
|
return ROUTE_LABELS[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compact Result label. A block is the answer the operator asked nxdns for,
|
||||||
|
* so it wins over the rcode it was delivered as — a blocked name answered with
|
||||||
|
* NOERROR and a zero address is still "Blocked". Everything else reads as the
|
||||||
|
* code the client saw.
|
||||||
|
*/
|
||||||
|
export function resultLabel(row: Pick<QuerySummary, "blocked" | "rcode">): string {
|
||||||
|
return row.blocked ? "Blocked" : rcodeShortName(row.rcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a row's domain is wrapped in. The caller owns the routes, so it builds
|
||||||
|
* the element; the look stays here, as `activityDomainLink`, which the caller
|
||||||
|
* spreads onto the control itself — a link's own colour beats one inherited
|
||||||
|
* from a wrapper. `id` is null for a streamed row, which history has no surface
|
||||||
|
* for and live opens from memory.
|
||||||
|
*/
|
||||||
|
export type DomainRenderer = (id: number | null, children: ReactNode) => ReactNode;
|
||||||
|
|
||||||
|
export const activityDomainLink = styles.domainLink;
|
||||||
|
|
||||||
|
export function ResultCellContent({ row }: { row: Pick<QuerySummary, "blocked" | "rcode"> }) {
|
||||||
|
const label = resultLabel(row);
|
||||||
|
if (row.blocked) return <span {...stylex.props(styles.badge, styles.blockedBadge)}>{label}</span>;
|
||||||
|
if (row.rcode !== 0) return <span {...stylex.props(styles.badge, styles.faultBadge)}>{label}</span>;
|
||||||
|
return <span {...stylex.props(styles.small, styles.muted)}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityCells({
|
||||||
|
row,
|
||||||
|
clientNames,
|
||||||
|
renderDomain,
|
||||||
|
}: {
|
||||||
|
row: QuerySummary;
|
||||||
|
clientNames: ClientNames;
|
||||||
|
renderDomain: DomainRenderer;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||||
|
{renderDomain(row.id, row.domain)}
|
||||||
|
</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||||
|
<ClientName ip={row.client_ip} names={clientNames} />
|
||||||
|
</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||||
|
<ResultCellContent row={row} />
|
||||||
|
</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||||
|
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
|
||||||
|
|
||||||
|
export function ActivityTableHead() {
|
||||||
|
return (
|
||||||
|
<thead {...stylex.props(styles.head)}>
|
||||||
|
<tr>
|
||||||
|
{ACTIVITY_COLUMNS.map((column) => (
|
||||||
|
<th key={column} {...stylex.props(styles.th)}>
|
||||||
|
{column}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
datetimeField,
|
||||||
|
datetimeLocalToUnix,
|
||||||
|
editDatetimeField,
|
||||||
|
resolveDatetimeField,
|
||||||
|
unixToDatetimeLocal,
|
||||||
|
} from "./datetime";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every assertion here is about local time, so the zone has to be pinned. New
|
||||||
|
* York is the zone the DST cases are written for: the fold is 2024-11-03 01:30
|
||||||
|
* and the gap is 2024-03-10 02:30.
|
||||||
|
*/
|
||||||
|
// The app never reads `process`, so `src` is typed without node's globals; the
|
||||||
|
// test host is node, where assigning `TZ` re-reads the zone for `Date`.
|
||||||
|
declare const process: { env: Record<string, string | undefined> };
|
||||||
|
|
||||||
|
const originalTz = process.env["TZ"];
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env["TZ"] = "America/New_York";
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
process.env["TZ"] = originalTz;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 2024-06-01T12:34:56 EDT. */
|
||||||
|
const SUMMER = 1_717_259_696;
|
||||||
|
|
||||||
|
test("a non-zero-second instant round trips", () => {
|
||||||
|
expect(unixToDatetimeLocal(SUMMER)).toBe("2024-06-01T12:34:56");
|
||||||
|
expect(datetimeLocalToUnix("2024-06-01T12:34:56")).toBe(SUMMER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text without seconds parses as :00", () => {
|
||||||
|
expect(datetimeLocalToUnix("2024-06-01T12:34")).toBe(datetimeLocalToUnix("2024-06-01T12:34:00"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text that is not a datetime-local value names no instant", () => {
|
||||||
|
expect(datetimeLocalToUnix("")).toBeUndefined();
|
||||||
|
expect(datetimeLocalToUnix("yesterday")).toBeUndefined();
|
||||||
|
expect(datetimeLocalToUnix("2024-06-01")).toBeUndefined();
|
||||||
|
expect(datetimeLocalToUnix("2024-13-01T00:00:00")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 2024-11-03 01:30 EDT and 01:30 EST: two instants, one wall clock. */
|
||||||
|
const FOLD_FIRST = 1_730_611_800;
|
||||||
|
const FOLD_SECOND = 1_730_615_400;
|
||||||
|
|
||||||
|
test("the fall-back fold gives two instants the same text", () => {
|
||||||
|
expect(unixToDatetimeLocal(FOLD_FIRST)).toBe("2024-11-03T01:30:00");
|
||||||
|
expect(unixToDatetimeLocal(FOLD_SECOND)).toBe("2024-11-03T01:30:00");
|
||||||
|
expect(datetimeLocalToUnix("2024-11-03T01:30:00")).toBe(FOLD_FIRST);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an untouched fold bound applies the instant it was seeded with, not a re-parse of its text", () => {
|
||||||
|
const field = datetimeField(FOLD_SECOND);
|
||||||
|
expect(field.text).toBe("2024-11-03T01:30:00");
|
||||||
|
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_SECOND });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an edited fold bound resolves to the first of the two instants, which is what its text says", () => {
|
||||||
|
const field = editDatetimeField(datetimeField(FOLD_SECOND), "2024-11-03T01:30:00");
|
||||||
|
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_FIRST });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a spring-forward time that exists on no clock is rejected rather than slid forward an hour", () => {
|
||||||
|
const field = editDatetimeField(datetimeField(undefined), "2024-03-10T02:30:00");
|
||||||
|
expect(datetimeLocalToUnix("2024-03-10T02:30:00")).toBe(1_710_055_800);
|
||||||
|
expect(unixToDatetimeLocal(1_710_055_800)).toBe("2024-03-10T03:30:00");
|
||||||
|
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "nonexistent" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an edited bound round trips with non-zero seconds", () => {
|
||||||
|
const field = editDatetimeField(datetimeField(undefined), "2024-06-01T12:34:56");
|
||||||
|
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: SUMMER });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing an edited bound drops the filter", () => {
|
||||||
|
const field = editDatetimeField(datetimeField(SUMMER), "");
|
||||||
|
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unparseable edit is reported, never silently dropped", () => {
|
||||||
|
const field = editDatetimeField(datetimeField(undefined), "2024-06-32T99:99");
|
||||||
|
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "unparseable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unset bound seeds an empty field that stays unset", () => {
|
||||||
|
const field = datetimeField(undefined);
|
||||||
|
expect(field.text).toBe("");
|
||||||
|
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a fractional part on the seconds is parsed and dropped, not rejected", () => {
|
||||||
|
// jsdom, and any engine that sanitizes to the full grammar, hands the input
|
||||||
|
// back with milliseconds attached; a bound is a whole second either way.
|
||||||
|
const field = editDatetimeField(datetimeField(undefined), "2023-11-14T23:13:37.000");
|
||||||
|
const resolved = resolveDatetimeField(field);
|
||||||
|
expect(resolved).toEqual({ ok: true, value: datetimeLocalToUnix("2023-11-14T23:13:37") });
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* The bridge between a `datetime-local` input and the unix seconds the URL and
|
||||||
|
* the API speak.
|
||||||
|
*
|
||||||
|
* Local wall-clock text is lossy in a way unix seconds are not. Twice a year a
|
||||||
|
* fall-back fold gives two instants the same text, and a spring-forward gap
|
||||||
|
* gives an hour of text no instant at all. So the text is never the authority:
|
||||||
|
* a bound the operator did not touch is carried through as the number it
|
||||||
|
* already was, and a bound they did edit is accepted only when it survives a
|
||||||
|
* round trip unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Zero-padded to the width the `datetime-local` grammar requires. */
|
||||||
|
function pad(value: number, width: number): string {
|
||||||
|
return String(value).padStart(width, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unix seconds → the local wall-clock text a `datetime-local` input holds,
|
||||||
|
* always with seconds, because the inputs run at `step={1}`.
|
||||||
|
*/
|
||||||
|
export function unixToDatetimeLocal(unix: number): string {
|
||||||
|
const date = new Date(unix * 1000);
|
||||||
|
const day = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`;
|
||||||
|
const time = `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}`;
|
||||||
|
return `${day}T${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATETIME_LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text with its seconds spelled out, or undefined when it is not a
|
||||||
|
* `datetime-local` value at all. A browser omits `:00` seconds even at
|
||||||
|
* `step={1}`, so the canonical form is what a round trip compares against.
|
||||||
|
*
|
||||||
|
* The grammar allows a fractional part after the seconds and some engines emit
|
||||||
|
* one; a bound is a whole second here and on the wire, so it is parsed and then
|
||||||
|
* dropped rather than treated as text we do not recognise.
|
||||||
|
*/
|
||||||
|
function canonicalize(value: string): string | undefined {
|
||||||
|
const match = DATETIME_LOCAL.exec(value);
|
||||||
|
if (match === null) return undefined;
|
||||||
|
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6] ?? "00"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local wall-clock text → unix seconds, or undefined when it names no instant. */
|
||||||
|
export function datetimeLocalToUnix(value: string): number | undefined {
|
||||||
|
const canonical = canonicalize(value);
|
||||||
|
if (canonical === undefined) return undefined;
|
||||||
|
const ms = new Date(canonical).getTime();
|
||||||
|
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bound of the filter form: what the input shows, what the applied search
|
||||||
|
* carried, and whether the operator has touched it since.
|
||||||
|
*/
|
||||||
|
export interface DatetimeField {
|
||||||
|
text: string;
|
||||||
|
/** The applied value this field was seeded from, reused while `dirty` is false. */
|
||||||
|
original: number | undefined;
|
||||||
|
dirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatetimeResolution =
|
||||||
|
{ ok: true; value: number | undefined } | { ok: false; reason: "unparseable" | "nonexistent" };
|
||||||
|
|
||||||
|
export function datetimeField(original: number | undefined): DatetimeField {
|
||||||
|
return { text: original === undefined ? "" : unixToDatetimeLocal(original), original, dirty: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editDatetimeField(field: DatetimeField, text: string): DatetimeField {
|
||||||
|
return { ...field, text, dirty: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unix value this bound applies.
|
||||||
|
*
|
||||||
|
* An untouched field resolves to the number it was seeded with, never to a
|
||||||
|
* re-parse of its own text: the text of a fall-back instant names two of them,
|
||||||
|
* and re-parsing would silently move a bound the operator never edited.
|
||||||
|
*
|
||||||
|
* An edited field is parsed, then formatted back. A wall-clock time inside the
|
||||||
|
* spring-forward gap exists on no clock, and `Date` quietly slides it forward
|
||||||
|
* an hour; the round trip catches that and the caller reports it instead of
|
||||||
|
* filtering on an hour nobody asked for.
|
||||||
|
*/
|
||||||
|
export function resolveDatetimeField(field: DatetimeField): DatetimeResolution {
|
||||||
|
if (!field.dirty) return { ok: true, value: field.original };
|
||||||
|
if (field.text.trim() === "") return { ok: true, value: undefined };
|
||||||
|
const unix = datetimeLocalToUnix(field.text);
|
||||||
|
if (unix === undefined) return { ok: false, reason: "unparseable" };
|
||||||
|
if (unixToDatetimeLocal(unix) !== canonicalize(field.text)) return { ok: false, reason: "nonexistent" };
|
||||||
|
return { ok: true, value: unix };
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* The absolute bounds an investigation link carries.
|
||||||
|
*
|
||||||
|
* Every link out of a query detail is time-scoped on purpose: a relative window
|
||||||
|
* would answer a different question tomorrow than it does today, and the whole
|
||||||
|
* point of linking to an episode is that the link keeps showing that episode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ActivitySearch } from "./search";
|
||||||
|
|
||||||
|
/** The five-minute window the redesign puts around one query. */
|
||||||
|
export const RELATED_WINDOW_SECONDS = 300;
|
||||||
|
|
||||||
|
export interface Bounds {
|
||||||
|
since: number;
|
||||||
|
until: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bounds for "all activity for this domain/client", decided per bound.
|
||||||
|
*
|
||||||
|
* When the reader arrived from a bounded investigation, that bound is the one
|
||||||
|
* they are working in and it carries over. A bound they never set falls back to
|
||||||
|
* the five-minute window around this query — never to no bound at all, which
|
||||||
|
* would answer with the whole retained history and lose the episode in it. The
|
||||||
|
* two bounds are decided separately, so a half-bounded origin keeps its half.
|
||||||
|
*/
|
||||||
|
export function relatedBounds(ts: number, origin: Pick<ActivitySearch, "since" | "until">): Bounds {
|
||||||
|
return {
|
||||||
|
since: origin.since ?? ts - RELATED_WINDOW_SECONDS,
|
||||||
|
until: origin.until ?? ts + RELATED_WINDOW_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Diagnostics window around one query. Fixed at five minutes either side of
|
||||||
|
* the query, not inherited: the reader is asking what else was failing while
|
||||||
|
* this query was answered, which is a question about the query's own moment.
|
||||||
|
*/
|
||||||
|
export function diagnosticsBounds(ts: number): Bounds {
|
||||||
|
return { since: ts - RELATED_WINDOW_SECONDS, until: ts + RELATED_WINDOW_SECONDS };
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import type { Provenance, QueryRow } from "@/lib/types";
|
||||||
|
import { provenance, queryRow } from "@/features/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 {
|
||||||
|
return {
|
||||||
|
kind: "streamed",
|
||||||
|
key,
|
||||||
|
event: provenance({
|
||||||
|
...sections,
|
||||||
|
request: { time: ts, domain, ...sections.request },
|
||||||
|
route: { upstream: "udp://9.9.9.9:53", ...sections.route },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchedRow(id: number, ts: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||||
|
return queryRow(id, { ts, domain, upstream: "udp://9.9.9.9:53", ...overrides });
|
||||||
|
}
|
||||||
|
|
||||||
|
function counter(start = 100): () => number {
|
||||||
|
let n = start;
|
||||||
|
return () => ++n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function domains(rows: LiveRow[]): string[] {
|
||||||
|
return rows.map((row) => summaryOf(row).domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("summaryOf", () => {
|
||||||
|
test("a streamed frame projects every summary field from the provenance it carries", () => {
|
||||||
|
const event: Provenance = provenance({
|
||||||
|
request: { time: 1700, domain: "ads.example", client: "192.0.2.11", qtype: 28 },
|
||||||
|
policy: { action: "block", reason: "blocklist_wildcard" },
|
||||||
|
route: { kind: "blocked", upstream: "" },
|
||||||
|
response: { duration_us: 42 },
|
||||||
|
});
|
||||||
|
expect(summaryOf({ kind: "streamed", key: 1, event })).toEqual({
|
||||||
|
id: null,
|
||||||
|
ts: 1700,
|
||||||
|
domain: "ads.example",
|
||||||
|
client_ip: "192.0.2.11",
|
||||||
|
qtype: 28,
|
||||||
|
blocked: true,
|
||||||
|
policy_reason: "blocklist_wildcard",
|
||||||
|
rcode: 0,
|
||||||
|
route_kind: "blocked",
|
||||||
|
response_time_us: 42,
|
||||||
|
cache_hit: null,
|
||||||
|
upstream: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a recovered row projects its stored fields and keeps its id", () => {
|
||||||
|
const row = queryRow(77, { domain: "news.example", cache_hit: true, policy_reason: "rule_allow_exact" });
|
||||||
|
expect(summaryOf({ kind: "recovered", key: 2, row })).toMatchObject({
|
||||||
|
id: 77,
|
||||||
|
domain: "news.example",
|
||||||
|
cache_hit: true,
|
||||||
|
policy_reason: "rule_allow_exact",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guard the discriminated union exists for: a field added to the wire
|
||||||
|
* DTO must be either projected into the summary or consciously left to the
|
||||||
|
* detail page. A silent addition fails here rather than going unrendered.
|
||||||
|
*/
|
||||||
|
test("every provenance field is either projected or knowingly detail-only", () => {
|
||||||
|
const projected = [
|
||||||
|
"request.time",
|
||||||
|
"request.domain",
|
||||||
|
"request.client",
|
||||||
|
"request.qtype",
|
||||||
|
"policy.action",
|
||||||
|
"policy.reason",
|
||||||
|
"route.kind",
|
||||||
|
"route.upstream",
|
||||||
|
"response.duration_us",
|
||||||
|
];
|
||||||
|
const detailOnly = [
|
||||||
|
"request.qclass",
|
||||||
|
"group.id",
|
||||||
|
"group.name",
|
||||||
|
"policy.matched",
|
||||||
|
"policy.source_id",
|
||||||
|
"policy.source_name",
|
||||||
|
"rewrites.cname_target",
|
||||||
|
"rewrites.safe_search_target",
|
||||||
|
"route.forward_zone",
|
||||||
|
"response.rcode",
|
||||||
|
];
|
||||||
|
const leaves = Object.entries(provenance()).flatMap(([section, fields]) =>
|
||||||
|
Object.keys(fields as Record<string, unknown>).map((field) => `${section}.${field}`),
|
||||||
|
);
|
||||||
|
expect(leaves.sort()).toEqual([...projected, ...detailOnly].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pushRow", () => {
|
||||||
|
test("prepends newest-first", () => {
|
||||||
|
let rows: LiveRow[] = [];
|
||||||
|
rows = pushRow(rows, streamed(1, 10, "a.example"));
|
||||||
|
rows = pushRow(rows, streamed(2, 11, "b.example"));
|
||||||
|
expect(domains(rows)).toEqual(["b.example", "a.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops the oldest beyond capacity", () => {
|
||||||
|
let rows: LiveRow[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) rows = pushRow(rows, streamed(i, i, `d${i}.example`), 3);
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
expect(rows.map((r) => r.key)).toEqual([4, 3, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default capacity is 500", () => {
|
||||||
|
let rows: LiveRow[] = [];
|
||||||
|
for (let i = 0; i < RING_CAPACITY + 10; i++) rows = pushRow(rows, streamed(i, i, "x.example"));
|
||||||
|
expect(rows).toHaveLength(RING_CAPACITY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeGap", () => {
|
||||||
|
test("skips rows already in the buffer and counts only new ones", () => {
|
||||||
|
const buffer = [streamed(2, 100, "seen.example"), streamed(1, 99, "old.example")];
|
||||||
|
const fetched = [
|
||||||
|
fetchedRow(30, 102, "gap2.example"),
|
||||||
|
fetchedRow(29, 101, "gap1.example"),
|
||||||
|
fetchedRow(28, 100, "seen.example"),
|
||||||
|
];
|
||||||
|
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||||
|
expect(missed).toBe(2);
|
||||||
|
expect(domains(rows)).toEqual(["gap2.example", "gap1.example", "seen.example", "old.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no additions returns the buffer unchanged with missed 0", () => {
|
||||||
|
const buffer = [streamed(1, 100, "seen.example")];
|
||||||
|
const { rows, missed } = mergeGap(buffer, [fetchedRow(5, 100, "seen.example")], counter());
|
||||||
|
expect(missed).toBe(0);
|
||||||
|
expect(rows).toBe(buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A household repeats itself: one client, one name, three lookups inside the
|
||||||
|
* same second. The stream delivered one of them before the connection broke,
|
||||||
|
* so the gap fetch must recover the other two rather than let the one row in
|
||||||
|
* the buffer stand for all three.
|
||||||
|
*/
|
||||||
|
test("repeated identical queries drop only as many rows as the buffer already holds", () => {
|
||||||
|
const buffer = [streamed(1, 100, "dup.example")];
|
||||||
|
const fetched = [
|
||||||
|
fetchedRow(12, 100, "dup.example"),
|
||||||
|
fetchedRow(11, 100, "dup.example"),
|
||||||
|
fetchedRow(10, 100, "dup.example"),
|
||||||
|
];
|
||||||
|
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||||
|
expect(missed).toBe(2);
|
||||||
|
expect(domains(rows)).toEqual(["dup.example", "dup.example", "dup.example"]);
|
||||||
|
const recoveredIds = rows.flatMap((row) => (row.kind === "recovered" ? [row.row.id] : []));
|
||||||
|
expect(new Set(recoveredIds).size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a gap fetch that repeats the whole buffer adds nothing", () => {
|
||||||
|
const buffer = [streamed(2, 100, "dup.example"), streamed(1, 100, "dup.example")];
|
||||||
|
const fetched = [fetchedRow(12, 100, "dup.example"), fetchedRow(11, 100, "dup.example")];
|
||||||
|
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||||
|
expect(missed).toBe(0);
|
||||||
|
expect(rows).toBe(buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two queries of the same name from the same client in the same second are
|
||||||
|
* still separate facts when any stored column differs — the record type or
|
||||||
|
* class, the response code, the policy that decided them, how long they took,
|
||||||
|
* the route taken. The gap fetch here returns the differing row *first* and
|
||||||
|
* the one the buffer already holds second, so an identity blind to the column
|
||||||
|
* would let the differing row consume the buffered occurrence: the buffered
|
||||||
|
* query would come back duplicated and the other would vanish, at an
|
||||||
|
* unchanged `missed`. Order is what exposes that — the count alone is 1
|
||||||
|
* either way.
|
||||||
|
*
|
||||||
|
* `blocked` and `cache_hit` have no case of their own: the server derives
|
||||||
|
* them from `policy_action` and `route_kind`, so they cannot differ while
|
||||||
|
* everything else holds, and the two columns they follow are covered here.
|
||||||
|
*/
|
||||||
|
test.each([
|
||||||
|
{ column: "qtype", sections: { request: { qtype: 1 } }, held: { qtype: 1 }, differing: { qtype: 28 } },
|
||||||
|
{ column: "qclass", sections: { request: { qclass: 1 } }, held: { qclass: 1 }, differing: { qclass: 3 } },
|
||||||
|
{ column: "rcode", sections: { response: { rcode: 0 } }, held: { rcode: 0 }, differing: { rcode: 2 } },
|
||||||
|
{
|
||||||
|
column: "response_time_us",
|
||||||
|
sections: { response: { duration_us: 1234 } },
|
||||||
|
held: { response_time_us: 1234 },
|
||||||
|
differing: { response_time_us: 9999 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: "route_kind",
|
||||||
|
sections: { route: { kind: "upstream" } },
|
||||||
|
held: { route_kind: "upstream" },
|
||||||
|
differing: { route_kind: "forward_zone" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: "policy_action",
|
||||||
|
sections: { policy: { action: "allow" } },
|
||||||
|
held: { policy_action: "allow" },
|
||||||
|
differing: { policy_action: "not_evaluated" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: "policy_reason",
|
||||||
|
sections: { policy: { reason: "no_match" } },
|
||||||
|
held: { policy_reason: "no_match" },
|
||||||
|
differing: { policy_reason: "rule_allow_exact" },
|
||||||
|
},
|
||||||
|
] satisfies readonly {
|
||||||
|
column: string;
|
||||||
|
sections: Parameters<typeof provenance>[0];
|
||||||
|
held: Partial<QueryRow>;
|
||||||
|
differing: Partial<QueryRow>;
|
||||||
|
}[])("rows differing only in $column survive the gap merge", ({ sections, held, differing }) => {
|
||||||
|
const buffer = [streamed(1, 100, "dual.example", sections)];
|
||||||
|
const fetched = [fetchedRow(6, 100, "dual.example", differing), fetchedRow(5, 100, "dual.example", held)];
|
||||||
|
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||||
|
expect(missed).toBe(1);
|
||||||
|
expect(rows).toEqual([{ kind: "recovered", key: expect.any(Number), row: fetched[0] }, buffer[0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recovered rows keep their id and take a fresh key", () => {
|
||||||
|
const { rows } = mergeGap([], [fetchedRow(77, 100, "gap.example")], counter(200));
|
||||||
|
const recovered = rows[0];
|
||||||
|
expect(recovered?.key).toBe(201);
|
||||||
|
expect(recovered?.kind).toBe("recovered");
|
||||||
|
expect(recovered !== undefined && recovered.kind === "recovered" ? recovered.row.id : null).toBe(77);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("result is capped at capacity, keeping the newest", () => {
|
||||||
|
const buffer = [streamed(3, 300, "live.example")];
|
||||||
|
const fetched = [fetchedRow(2, 302, "g2.example"), fetchedRow(1, 301, "g1.example")];
|
||||||
|
const { rows, missed } = mergeGap(buffer, fetched, counter(), 2);
|
||||||
|
expect(missed).toBe(2);
|
||||||
|
expect(domains(rows)).toEqual(["g2.example", "g1.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merged rows stay sorted newest-first by ts", () => {
|
||||||
|
const buffer = [streamed(4, 105, "after-reopen.example"), streamed(3, 100, "before.example")];
|
||||||
|
const fetched = [fetchedRow(9, 103, "gap.example")];
|
||||||
|
const { rows } = mergeGap(buffer, fetched, counter());
|
||||||
|
expect(rows.map((row) => summaryOf(row).ts)).toEqual([105, 103, 100]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
|
||||||
|
import { summarizeEvent, summarizeRow, type QuerySummary } from "@/features/provenance/querySummary";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row in the live buffer. `key` is a client-side monotonic counter, because
|
||||||
|
* neither arm has a stable identity of its own on arrival.
|
||||||
|
*
|
||||||
|
* The two arms are genuinely different facts, not two encodings of one. A
|
||||||
|
* streamed frame carries the full provenance of a query the server has not
|
||||||
|
* written yet; a row recovered by the reconnect gap-fetch is the stored summary
|
||||||
|
* of a query that *was* written, and cannot fabricate the provenance it never
|
||||||
|
* received. Only the recovered arm has a row id to link to.
|
||||||
|
*/
|
||||||
|
export type LiveRow = { key: number } & (
|
||||||
|
{ kind: "streamed"; event: LiveQueryEvent } | { kind: "recovered"; row: QueryRow }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The arm that carries its own provenance, and so its own detail surface. */
|
||||||
|
export type StreamedRow = Extract<LiveRow, { kind: "streamed" }>;
|
||||||
|
|
||||||
|
/** The flat cells both arms render, and the shared identity for gap dedupe. */
|
||||||
|
export function summaryOf(row: LiveRow): QuerySummary {
|
||||||
|
return row.kind === "streamed" ? summarizeEvent(row.event) : summarizeRow(row.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RING_CAPACITY = 500;
|
||||||
|
|
||||||
|
/** Prepend `row` (rows are newest-first) and drop the oldest beyond `capacity`. */
|
||||||
|
export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_CAPACITY): LiveRow[] {
|
||||||
|
const next = [row, ...rows];
|
||||||
|
return next.length > capacity ? next.slice(0, capacity) : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the gap merge compares two queries by: the whole stored row bar its id.
|
||||||
|
*
|
||||||
|
* `since` on GET /api/queries is inclusive, so the re-sync fetch returns the
|
||||||
|
* last-seen row(s) again and the merge has to recognise them. The id cannot
|
||||||
|
* serve as the identity — a streamed frame precedes its own insert and has none
|
||||||
|
* — so the comparison is by value, and every stored column has to take part.
|
||||||
|
* Two queries alike in name, client and second but differing in class, rcode,
|
||||||
|
* the policy that decided them or the route taken are separate facts; if they
|
||||||
|
* hashed alike, the fetched row that does *not* match the buffered one would
|
||||||
|
* consume its occurrence, duplicating one query and losing the other.
|
||||||
|
*
|
||||||
|
* `QuerySummary` is the wrong basis for that: it is what the table renders, and
|
||||||
|
* it drops qclass and policy_action. `Omit<QueryRow, "id">`
|
||||||
|
* instead makes the compiler demand a derivation for every stored column, so a
|
||||||
|
* column added to the row cannot quietly fall out of the identity.
|
||||||
|
*/
|
||||||
|
type GapIdentity = Omit<QueryRow, "id">;
|
||||||
|
|
||||||
|
function identityOfRow(row: QueryRow): GapIdentity {
|
||||||
|
return {
|
||||||
|
ts: row.ts,
|
||||||
|
domain: row.domain,
|
||||||
|
client_ip: row.client_ip,
|
||||||
|
qtype: row.qtype,
|
||||||
|
qclass: row.qclass,
|
||||||
|
rcode: row.rcode,
|
||||||
|
blocked: row.blocked,
|
||||||
|
response_time_us: row.response_time_us,
|
||||||
|
cache_hit: row.cache_hit,
|
||||||
|
upstream: row.upstream,
|
||||||
|
policy_action: row.policy_action,
|
||||||
|
policy_reason: row.policy_reason,
|
||||||
|
route_kind: row.route_kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same identity out of a live frame, which carries every stored column in
|
||||||
|
* its provenance. The columns the server derives rather than sends — `blocked`
|
||||||
|
* and `cache_hit` — come through `summarizeEvent` so that derivation keeps
|
||||||
|
* living in exactly one place.
|
||||||
|
*/
|
||||||
|
function identityOfEvent(event: LiveQueryEvent): GapIdentity {
|
||||||
|
const summary = summarizeEvent(event);
|
||||||
|
return {
|
||||||
|
ts: summary.ts,
|
||||||
|
domain: summary.domain,
|
||||||
|
client_ip: summary.client_ip,
|
||||||
|
qtype: summary.qtype,
|
||||||
|
qclass: event.request.qclass,
|
||||||
|
rcode: event.response.rcode,
|
||||||
|
blocked: summary.blocked,
|
||||||
|
response_time_us: summary.response_time_us,
|
||||||
|
cache_hit: summary.cache_hit,
|
||||||
|
upstream: summary.upstream,
|
||||||
|
policy_action: event.policy.action,
|
||||||
|
policy_reason: summary.policy_reason,
|
||||||
|
route_kind: event.route.kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityOf(row: LiveRow): GapIdentity {
|
||||||
|
return row.kind === "streamed" ? identityOfEvent(row.event) : identityOfRow(row.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sorted keys so the hash cannot depend on the order the two arms happen to build their literals in. */
|
||||||
|
function signature(identity: GapIdentity): string {
|
||||||
|
return JSON.stringify(identity, Object.keys(identity).sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many times each signature is already in the buffer. A signature is not
|
||||||
|
* unique: one client asking for one name twice within the same second is an
|
||||||
|
* ordinary household pattern, and the two queries are separate facts. Counting
|
||||||
|
* the occurrences lets the merge drop exactly as many fetched rows as the
|
||||||
|
* buffer already holds, instead of letting one buffered row hide all of them.
|
||||||
|
*/
|
||||||
|
function occurrences(rows: LiveRow[]): Map<string, number> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = signature(identityOf(row));
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge rows fetched for a reconnect gap (newest-first, from GET /api/queries)
|
||||||
|
* into the buffer. Each fetched row consumes one buffered occurrence of its
|
||||||
|
* signature and is skipped; the rest are genuinely missed and `missed` counts
|
||||||
|
* them. The result stays newest-first (stable sort by ts) and capped.
|
||||||
|
*/
|
||||||
|
export function mergeGap(
|
||||||
|
rows: LiveRow[],
|
||||||
|
fetched: QueryRow[],
|
||||||
|
nextKey: () => number,
|
||||||
|
capacity: number = RING_CAPACITY,
|
||||||
|
): { rows: LiveRow[]; missed: number } {
|
||||||
|
const buffered = occurrences(rows);
|
||||||
|
const added: LiveRow[] = [];
|
||||||
|
for (const row of fetched) {
|
||||||
|
const key = signature(identityOfRow(row));
|
||||||
|
const count = buffered.get(key) ?? 0;
|
||||||
|
if (count > 0) {
|
||||||
|
buffered.set(key, count - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
added.push({ kind: "recovered", row, key: nextKey() });
|
||||||
|
}
|
||||||
|
if (added.length === 0) return { rows, missed: 0 };
|
||||||
|
const merged = [...added, ...rows].sort((a, b) => summaryOf(b).ts - summaryOf(a).ts).slice(0, capacity);
|
||||||
|
return { rows: merged, missed: added.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { validateActivitySearch, validateBlocked, validateMode, validateText, validateTimestamp } from "./search";
|
||||||
|
|
||||||
|
test("mode is the two-value union, defaulting to history", () => {
|
||||||
|
expect(validateMode("live")).toBe("live");
|
||||||
|
expect(validateMode("history")).toBe("history");
|
||||||
|
expect(validateMode(undefined)).toBe("history");
|
||||||
|
expect(validateMode("Live")).toBe("history");
|
||||||
|
expect(validateMode("")).toBe("history");
|
||||||
|
expect(validateMode(0)).toBe("history");
|
||||||
|
expect(validateMode(["live"])).toBe("history");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bound is a safe integer or nothing at all", () => {
|
||||||
|
expect(validateTimestamp(1_700_000_000)).toBe(1_700_000_000);
|
||||||
|
expect(validateTimestamp(0)).toBe(0);
|
||||||
|
expect(validateTimestamp(-1)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["a fraction", 1_700_000_000.5],
|
||||||
|
["Infinity", Number.POSITIVE_INFINITY],
|
||||||
|
["-Infinity", Number.NEGATIVE_INFINITY],
|
||||||
|
["NaN", Number.NaN],
|
||||||
|
["past the safe range", Number.MAX_SAFE_INTEGER + 1],
|
||||||
|
["1e21", 1e21],
|
||||||
|
["a numeric string", "1700000000"],
|
||||||
|
["an empty string", ""],
|
||||||
|
["null", null],
|
||||||
|
["undefined", undefined],
|
||||||
|
["a boolean", true],
|
||||||
|
["an array", [1_700_000_000]],
|
||||||
|
["a bigint", 1_700_000_000n],
|
||||||
|
])("a bound rejects %s", (_name, value) => {
|
||||||
|
expect(validateTimestamp(value)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocked keeps false, which is the allowed-only filter", () => {
|
||||||
|
expect(validateBlocked(true)).toBe(true);
|
||||||
|
expect(validateBlocked(false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["the string true", "true"],
|
||||||
|
["the string false", "false"],
|
||||||
|
["1", 1],
|
||||||
|
["0", 0],
|
||||||
|
["null", null],
|
||||||
|
["undefined", undefined],
|
||||||
|
])("blocked rejects %s", (_name, value) => {
|
||||||
|
expect(validateBlocked(value)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a text filter is trimmed, and an empty one is no filter", () => {
|
||||||
|
expect(validateText("ads.example")).toBe("ads.example");
|
||||||
|
expect(validateText(" ads.example ")).toBe("ads.example");
|
||||||
|
expect(validateText("")).toBeUndefined();
|
||||||
|
expect(validateText(" ")).toBeUndefined();
|
||||||
|
expect(validateText("\t\n")).toBeUndefined();
|
||||||
|
expect(validateText(42)).toBeUndefined();
|
||||||
|
expect(validateText(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a whole search normalizes every field and drops nothing else in", () => {
|
||||||
|
expect(
|
||||||
|
validateActivitySearch({
|
||||||
|
mode: "live",
|
||||||
|
since: 1_700_000_000,
|
||||||
|
until: 1_700_000_600,
|
||||||
|
domain: " ads.example ",
|
||||||
|
client: "192.0.2.10",
|
||||||
|
blocked: false,
|
||||||
|
unknown: "kept out",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
mode: "live",
|
||||||
|
since: 1_700_000_000,
|
||||||
|
until: 1_700_000_600,
|
||||||
|
domain: "ads.example",
|
||||||
|
client: "192.0.2.10",
|
||||||
|
blocked: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty search is history with no filters", () => {
|
||||||
|
expect(validateActivitySearch({})).toEqual({
|
||||||
|
mode: "history",
|
||||||
|
since: undefined,
|
||||||
|
until: undefined,
|
||||||
|
domain: undefined,
|
||||||
|
client: undefined,
|
||||||
|
blocked: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a search of junk applies nothing", () => {
|
||||||
|
expect(
|
||||||
|
validateActivitySearch({
|
||||||
|
mode: "HISTORY ",
|
||||||
|
since: "1700000000",
|
||||||
|
until: Number.POSITIVE_INFINITY,
|
||||||
|
domain: " ",
|
||||||
|
client: null,
|
||||||
|
blocked: "true",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
mode: "history",
|
||||||
|
since: undefined,
|
||||||
|
until: undefined,
|
||||||
|
domain: undefined,
|
||||||
|
client: undefined,
|
||||||
|
blocked: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* The Activity search parameters, validated as pure functions so the route's
|
||||||
|
* `validateSearch` stays a one-liner and every rejection is testable without a
|
||||||
|
* router.
|
||||||
|
*
|
||||||
|
* A search value arrives from a URL, from history state, or from a hand-typed
|
||||||
|
* link, so nothing about its type is given. Anything that is not exactly the
|
||||||
|
* value the API can filter on becomes `undefined`: an unbounded page is honest,
|
||||||
|
* a page filtered on a coerced guess is not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { QueriesFilter } from "@/lib/types";
|
||||||
|
|
||||||
|
export const ACTIVITY_MODES = ["history", "live"] as const;
|
||||||
|
export type ActivityMode = (typeof ACTIVITY_MODES)[number];
|
||||||
|
|
||||||
|
export interface ActivitySearch {
|
||||||
|
mode: ActivityMode;
|
||||||
|
since: number | undefined;
|
||||||
|
until: number | undefined;
|
||||||
|
domain: string | undefined;
|
||||||
|
client: string | undefined;
|
||||||
|
blocked: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** History is the surface a bare `/activity` should open on: it answers questions. */
|
||||||
|
export function validateMode(value: unknown): ActivityMode {
|
||||||
|
return value === "live" ? "live" : "history";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unix-second bound. `Number.isSafeInteger` is the whole test: it rejects a
|
||||||
|
* fraction, an infinity, a NaN and a magnitude past 2^53 in one step, and a
|
||||||
|
* string never passes, so `?since=now` cannot reach the API as garbage.
|
||||||
|
*/
|
||||||
|
export function validateTimestamp(value: unknown): number | undefined {
|
||||||
|
return Number.isSafeInteger(value) ? (value as number) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The blocked filter. `false` is a real filter — "allowed only" — so it must
|
||||||
|
* survive; only a genuine boolean does, because `"false"` out of a URL parser
|
||||||
|
* that did not decode JSON would otherwise read as true.
|
||||||
|
*/
|
||||||
|
export function validateBlocked(value: unknown): boolean | undefined {
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text filter, trimmed. An empty result becomes `undefined` rather than `""`:
|
||||||
|
* the server treats an empty filter as no filter, and a URL that showed
|
||||||
|
* `domain=` as applied state would claim a filter that is not filtering.
|
||||||
|
*/
|
||||||
|
export function validateText(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed === "" ? undefined : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API filter for a validated search, built field by field.
|
||||||
|
*
|
||||||
|
* Only the fields that are actually set are written, so an unfiltered request
|
||||||
|
* carries no keys at all: `GET /api/queries` rejects a parameter it does not
|
||||||
|
* know, and the infinite query's cache key is the filter object, so a key
|
||||||
|
* present-but-undefined and a key absent must not be two different windows onto
|
||||||
|
* the same rows. `mode` never appears — it selects the surface, not the rows.
|
||||||
|
*/
|
||||||
|
export function queriesFilterOf(search: Omit<ActivitySearch, "mode">): QueriesFilter {
|
||||||
|
const filter: QueriesFilter = {};
|
||||||
|
if (search.domain !== undefined) filter.domain = search.domain;
|
||||||
|
if (search.client !== undefined) filter.client = search.client;
|
||||||
|
if (search.blocked !== undefined) filter.blocked = search.blocked;
|
||||||
|
if (search.since !== undefined) filter.since = search.since;
|
||||||
|
if (search.until !== undefined) filter.until = search.until;
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateActivitySearch(search: Record<string, unknown>): ActivitySearch {
|
||||||
|
return {
|
||||||
|
mode: validateMode(search["mode"]),
|
||||||
|
since: validateTimestamp(search["since"]),
|
||||||
|
until: validateTimestamp(search["until"]),
|
||||||
|
domain: validateText(search["domain"]),
|
||||||
|
client: validateText(search["client"]),
|
||||||
|
blocked: validateBlocked(search["blocked"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
+24
-34
@@ -1,6 +1,8 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import type { LiveQueryEvent, QueriesPage, QueryRow } from "@/lib/types";
|
import type { QueriesPage, QueryRow } from "@/lib/types";
|
||||||
|
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
|
||||||
|
import { summaryOf, type LiveRow } from "./ringBuffer";
|
||||||
import { FakeEventSource } from "./fakeEventSource";
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
|
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
|
||||||
|
|
||||||
@@ -8,41 +10,28 @@ afterEach(() => vi.unstubAllGlobals());
|
|||||||
|
|
||||||
function stubLocationAssign() {
|
function stubLocationAssign() {
|
||||||
const assign = vi.fn();
|
const assign = vi.fn();
|
||||||
vi.stubGlobal("location", { pathname: "/live", search: "", assign });
|
vi.stubGlobal("location", { pathname: "/activity", search: "?mode=live", assign });
|
||||||
return assign;
|
return assign;
|
||||||
}
|
}
|
||||||
|
|
||||||
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
|
function frame(ts: number, domain: string): { data: string } {
|
||||||
const payload: LiveQueryEvent = {
|
const payload = provenance({
|
||||||
ts,
|
request: { time: ts, domain },
|
||||||
domain,
|
route: { upstream: "udp://9.9.9.9:53" },
|
||||||
client_ip: "192.0.2.10",
|
});
|
||||||
qtype: 1,
|
|
||||||
blocked: false,
|
|
||||||
block_reason: "",
|
|
||||||
response_time_us: 500,
|
|
||||||
cache_hit: false,
|
|
||||||
upstream: "udp://9.9.9.9:53",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
return { data: JSON.stringify(payload) };
|
return { data: JSON.stringify(payload) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchedRow(id: number, ts: number, domain: string): QueryRow {
|
function fetchedRow(id: number, ts: number, domain: string): QueryRow {
|
||||||
return {
|
return queryRow(id, { ts, domain, upstream: "udp://9.9.9.9:53" });
|
||||||
id,
|
|
||||||
ts,
|
|
||||||
domain,
|
|
||||||
client_ip: "192.0.2.10",
|
|
||||||
qtype: 1,
|
|
||||||
blocked: false,
|
|
||||||
block_reason: "",
|
|
||||||
response_time_us: 500,
|
|
||||||
cache_hit: false,
|
|
||||||
upstream: "udp://9.9.9.9:53",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function domains(rows: LiveRow[]): string[] {
|
||||||
|
return rows.map((row) => summaryOf(row).domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FULL_COVERAGE = { complete: true, available_since: 0 };
|
||||||
|
|
||||||
function setup(fetchSince?: (since: number) => Promise<QueriesPage>, probeSession?: () => Promise<unknown>) {
|
function setup(fetchSince?: (since: number) => Promise<QueriesPage>, probeSession?: () => Promise<unknown>) {
|
||||||
const sources: FakeEventSource[] = [];
|
const sources: FakeEventSource[] = [];
|
||||||
const createEventSource = (url: string) => {
|
const createEventSource = (url: string) => {
|
||||||
@@ -68,7 +57,7 @@ test("open then frames: rows newest-first with increasing keys", () => {
|
|||||||
sources[0]!.emit("query", frame(1001, "b.example"));
|
sources[0]!.emit("query", frame(1001, "b.example"));
|
||||||
});
|
});
|
||||||
const rows = hook.result.current.rows;
|
const rows = hook.result.current.rows;
|
||||||
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
|
expect(domains(rows)).toEqual(["b.example", "a.example"]);
|
||||||
expect(rows[0]!.key).toBeGreaterThan(rows[1]!.key);
|
expect(rows[0]!.key).toBeGreaterThan(rows[1]!.key);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +76,7 @@ test("error then reopen re-syncs the gap since the last seen ts", async () => {
|
|||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
queries: [fetchedRow(9, 1002, "gap.example"), fetchedRow(8, since, "a.example")],
|
queries: [fetchedRow(9, 1002, "gap.example"), fetchedRow(8, since, "a.example")],
|
||||||
next_before: null,
|
next_before: null,
|
||||||
|
coverage: FULL_COVERAGE,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const { sources, hook } = setup(fetchSince);
|
const { sources, hook } = setup(fetchSince);
|
||||||
@@ -103,7 +93,7 @@ test("error then reopen re-syncs the gap since the last seen ts", async () => {
|
|||||||
expect(fetchSince).toHaveBeenCalledWith(1000);
|
expect(fetchSince).toHaveBeenCalledWith(1000);
|
||||||
|
|
||||||
await waitFor(() => expect(hook.result.current.missed).toBe(1));
|
await waitFor(() => expect(hook.result.current.missed).toBe(1));
|
||||||
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["gap.example", "a.example"]);
|
expect(domains(hook.result.current.rows)).toEqual(["gap.example", "a.example"]);
|
||||||
|
|
||||||
act(() => hook.result.current.dismissMissed());
|
act(() => hook.result.current.dismissMissed());
|
||||||
expect(hook.result.current.missed).toBeNull();
|
expect(hook.result.current.missed).toBeNull();
|
||||||
@@ -127,7 +117,7 @@ test("a 401 gap re-sync redirects to login instead of setting resyncFailed", asy
|
|||||||
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||||
act(() => sources[0]!.emit("error"));
|
act(() => sources[0]!.emit("error"));
|
||||||
act(() => sources[0]!.emit("open"));
|
act(() => sources[0]!.emit("open"));
|
||||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||||
expect(hook.result.current.resyncFailed).toBe(false);
|
expect(hook.result.current.resyncFailed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,7 +142,7 @@ test("cap trip with an expired session redirects to login", async () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||||
});
|
});
|
||||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,7 +183,7 @@ test("a fatal rejection with an expired session redirects to login", async () =>
|
|||||||
|
|
||||||
act(() => sources[0]!.failFatal());
|
act(() => sources[0]!.failFatal());
|
||||||
|
|
||||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,12 +225,12 @@ test("freeze keeps the display fixed while the buffer keeps filling", () => {
|
|||||||
sources[0]!.emit("query", frame(1001, "b.example"));
|
sources[0]!.emit("query", frame(1001, "b.example"));
|
||||||
sources[0]!.emit("query", frame(1002, "c.example"));
|
sources[0]!.emit("query", frame(1002, "c.example"));
|
||||||
});
|
});
|
||||||
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["a.example"]);
|
expect(domains(hook.result.current.rows)).toEqual(["a.example"]);
|
||||||
expect(hook.result.current.liveCount).toBe(3);
|
expect(hook.result.current.liveCount).toBe(3);
|
||||||
|
|
||||||
act(() => hook.result.current.toggleFreeze());
|
act(() => hook.result.current.toggleFreeze());
|
||||||
expect(hook.result.current.frozen).toBe(false);
|
expect(hook.result.current.frozen).toBe(false);
|
||||||
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["c.example", "b.example", "a.example"]);
|
expect(domains(hook.result.current.rows)).toEqual(["c.example", "b.example", "a.example"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stale sources are ignored after retry and closed on unmount", () => {
|
test("stale sources are ignored after retry and closed on unmount", () => {
|
||||||
+19
-5
@@ -24,6 +24,12 @@ export interface LiveQueriesOptions {
|
|||||||
fetchSince?: (since: number) => Promise<QueriesPage>;
|
fetchSince?: (since: number) => Promise<QueriesPage>;
|
||||||
/** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */
|
/** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */
|
||||||
probeSession?: () => Promise<unknown>;
|
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;
|
// 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;
|
export const CAP_ERROR_THRESHOLD = 3;
|
||||||
|
|
||||||
const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
|
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();
|
const defaultProbeSession = (): Promise<unknown> => api.getPause();
|
||||||
|
|
||||||
function isUnauthorized(error: unknown): boolean {
|
function isUnauthorized(error: unknown): boolean {
|
||||||
@@ -79,7 +88,8 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
|||||||
errorsRef.current = 0;
|
errorsRef.current = 0;
|
||||||
setStatus("connecting");
|
setStatus("connecting");
|
||||||
const opts = optionsRef.current;
|
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 probeSession = opts?.probeSession ?? defaultProbeSession;
|
||||||
const es = (opts?.createEventSource ?? defaultEventSource)(opts?.url ?? api.liveQueriesUrl);
|
const es = (opts?.createEventSource ?? defaultEventSource)(opts?.url ?? api.liveQueriesUrl);
|
||||||
esRef.current = es;
|
esRef.current = es;
|
||||||
@@ -94,7 +104,7 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
|||||||
fetchSince(since).then(
|
fetchSince(since).then(
|
||||||
(page) => {
|
(page) => {
|
||||||
if (esRef.current !== es) return;
|
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;
|
bufferRef.current = merged.rows;
|
||||||
setRows(merged.rows);
|
setRows(merged.rows);
|
||||||
setMissed(merged.missed);
|
setMissed(merged.missed);
|
||||||
@@ -121,8 +131,12 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastSeenTsRef.current = payload.ts;
|
lastSeenTsRef.current = payload.request.time;
|
||||||
bufferRef.current = pushRow(bufferRef.current, { ...payload, key: ++keyRef.current });
|
bufferRef.current = pushRow(
|
||||||
|
bufferRef.current,
|
||||||
|
{ kind: "streamed", event: payload, key: ++keyRef.current },
|
||||||
|
capacity,
|
||||||
|
);
|
||||||
setRows(bufferRef.current);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import * as stylex from "@stylexjs/stylex";
|
|||||||
import { clientUpdateMutation } from "@/lib/queries";
|
import { clientUpdateMutation } from "@/lib/queries";
|
||||||
import type { Client, Group } from "@/lib/types";
|
import type { Client, Group } from "@/lib/types";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
||||||
|
import { useReadOnlyConfig } from "@/features/configuration/authority";
|
||||||
import Dialog from "@/ui/Dialog";
|
import Dialog from "@/ui/Dialog";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: Client;
|
client: Client;
|
||||||
@@ -39,20 +41,30 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
},
|
},
|
||||||
|
lockNote: {
|
||||||
|
marginRight: "auto",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const mutation = useMutation(clientUpdateMutation(queryClient));
|
const mutation = useMutation(clientUpdateMutation(queryClient));
|
||||||
const readOnly = useReadOnlyConfig();
|
// Adopting the learned name as a typed one is the natural gesture.
|
||||||
// Adopting the learned name as a typed one is the natural gesture, but only
|
const [name, setName] = useState(client.name === "" ? client.learned_name : client.name);
|
||||||
// where the save can land: under file authority the PUT answers 403, and the
|
|
||||||
// file's declared name is the one that wins.
|
|
||||||
const [name, setName] = useState(client.name === "" && !readOnly ? client.learned_name : client.name);
|
|
||||||
const [groupId, setGroupId] = useState(client.group_id);
|
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 (
|
return (
|
||||||
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||||
@@ -61,6 +73,9 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
|||||||
{...stylex.props(styles.form)}
|
{...stylex.props(styles.form)}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
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(
|
mutation.mutate(
|
||||||
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
|
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
|
||||||
{ onSuccess: onClose },
|
{ onSuccess: onClose },
|
||||||
@@ -86,17 +101,24 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
<InlineError error={mutation.error} />
|
<InlineError error={mutation.error} />
|
||||||
<div {...stylex.props(styles.actions)}>
|
<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)}>
|
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!readOnly && (
|
||||||
type="submit"
|
<button
|
||||||
disabled={mutation.isPending || readOnly}
|
type="submit"
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
disabled={mutation.isPending}
|
||||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,121 +1,78 @@
|
|||||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { ClientName, type ClientNames } from "./clientNames";
|
||||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||||
import { AuthProvider } from "@/auth/store";
|
|
||||||
import { createQueryClient } from "@/lib/queryClient";
|
|
||||||
import { createAppRouter } from "@/routes";
|
|
||||||
|
|
||||||
const GROUPS = {
|
function clientRow(ip: string): HTMLElement {
|
||||||
groups: [
|
const row = screen.getByText(ip).closest("tr");
|
||||||
{ id: 1, name: "default", safe_search: false },
|
if (row === null) throw new Error(`no client row for ${ip}`);
|
||||||
{ id: 2, name: "kids", safe_search: true },
|
return row;
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
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" },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderClientsPage(map: Record<string, unknown>) {
|
function assignmentsSection(): HTMLElement {
|
||||||
stubFetch(map);
|
const heading = screen.getByRole("heading", { name: /Network assignments/ });
|
||||||
const queryClient = createQueryClient();
|
const section = heading.closest("section");
|
||||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient);
|
if (section === null) throw new Error("no network assignments section");
|
||||||
render(
|
return section;
|
||||||
<AuthProvider>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</AuthProvider>,
|
|
||||||
);
|
|
||||||
await screen.findByRole("heading", { name: "Clients" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE = {
|
/**
|
||||||
"GET /api/clients": CLIENTS,
|
* Editing a client writes configuration, so the affordance is absent until the
|
||||||
"GET /api/client-prefixes": PREFIXES,
|
* status query says the database owns it — never disabled, never assumed.
|
||||||
"GET /api/groups": GROUPS,
|
*/
|
||||||
"GET /api/version": VERSION,
|
async function unlockedEdit(index: number): Promise<HTMLButtonElement> {
|
||||||
};
|
const buttons = await screen.findAllByRole("button", { name: "Edit" });
|
||||||
|
return buttons[index] as HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders the client table with group names and one hand-edited badge", async () => {
|
test("renders the identity-first table: address, name, group, first and last seen", async () => {
|
||||||
await renderClientsPage(BASE);
|
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.10")).toBeTruthy();
|
||||||
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||||
expect(screen.getByText("laptop")).toBeTruthy();
|
expect(screen.getByText("laptop")).toBeTruthy();
|
||||||
expect(screen.getAllByText("edited")).toHaveLength(1);
|
|
||||||
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
|
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a named row shows the typed name and hides the learned one", async () => {
|
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.getByText("laptop")).toBeTruthy();
|
||||||
expect(screen.queryByText("laptop-1.lan")).toBeNull();
|
expect(screen.queryByText("laptop-1.lan")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an unnamed row shows the learned name with the learned affordance", async () => {
|
test("an unnamed row shows the learned name with the learned marker", async () => {
|
||||||
await renderClientsPage(BASE);
|
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.
|
// the containing span rather than on a bare text node.
|
||||||
const learned = screen.getByText(
|
const learned = screen.getByText(
|
||||||
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
|
(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();
|
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("shows the DNS-activity empty state when there are no clients", async () => {
|
test("shows the DNS-activity empty state when there are no clients", async () => {
|
||||||
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||||
|
|
||||||
@@ -124,9 +81,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 () => {
|
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.
|
// 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" }));
|
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
|
||||||
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||||
@@ -135,9 +92,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 () => {
|
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" }));
|
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
|
||||||
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
|
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
|
||||||
|
|
||||||
@@ -148,14 +105,178 @@ test("the group picker offers every group and reports the choice", async () => {
|
|||||||
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
|
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prefix editor starts clean and dirties on add", async () => {
|
test("network assignments start clean and dirty on add", async () => {
|
||||||
await renderClientsPage(BASE);
|
await renderClientsPage();
|
||||||
|
|
||||||
expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24");
|
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||||
const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement;
|
expect(range.value).toBe("192.168.1.0/24");
|
||||||
|
const save = screen.getByRole("button", { name: "Save assignments" }) as HTMLButtonElement;
|
||||||
expect(save.disabled).toBe(true);
|
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(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({});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
await screen.findAllByLabelText(/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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.findAllByLabelText(/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.
|
||||||
|
//
|
||||||
|
// 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/;
|
||||||
|
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).getByLabelText(/^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 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" }));
|
||||||
|
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
|
||||||
|
|
||||||
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
|
||||||
|
const confirming = clientRow("192.168.1.10");
|
||||||
|
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
|
||||||
|
expect(within(confirming).getByText(lockNote)).toBeTruthy();
|
||||||
|
expect(within(confirming).queryByText(otherNote)).toBeNull();
|
||||||
|
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
|
||||||
|
);
|
||||||
|
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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" }));
|
||||||
|
|
||||||
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
|
||||||
|
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm 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.findAllByLabelText(/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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, useSearch } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
import type { Client } from "@/lib/types";
|
import type { Client, Group } from "@/lib/types";
|
||||||
import ClientEditDialog from "./ClientEditDialog";
|
import ClientEditDialog from "./ClientEditDialog";
|
||||||
import PrefixesEditor from "./PrefixesEditor";
|
import NetworkAssignments from "./NetworkAssignments";
|
||||||
|
import { ClientDisplayName } from "./clientIdentity";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
||||||
|
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
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
|
* Deleting an observed row discards runtime state the file never declared, so
|
||||||
* it stays live under file authority; deleting a hand-edited row contradicts
|
* it stays live under every authority; deleting a declared row contradicts the
|
||||||
* the file and is the one client DELETE the server answers 403 (ruling 7).
|
* 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.";
|
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.";
|
||||||
|
}
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
heading: {
|
heading: {
|
||||||
@@ -28,6 +41,15 @@ const styles = stylex.create({
|
|||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
|
filterBar: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
table: {
|
table: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
minWidth: "48rem",
|
minWidth: "48rem",
|
||||||
@@ -53,19 +75,9 @@ const styles = stylex.create({
|
|||||||
right: {
|
right: {
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
},
|
},
|
||||||
dash: {
|
addressLink: {
|
||||||
color: colors.textMuted,
|
color: colors.primaryOnSurface,
|
||||||
},
|
textDecorationLine: "none",
|
||||||
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: {
|
confirmGroup: {
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
@@ -76,6 +88,7 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
actionGroup: {
|
actionGroup: {
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
},
|
},
|
||||||
note: {
|
note: {
|
||||||
@@ -89,36 +102,54 @@ const styles = stylex.create({
|
|||||||
dimWhenDisabled: {
|
dimWhenDisabled: {
|
||||||
opacity: { default: 1, ":disabled": 0.5 },
|
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() {
|
export default function ClientsPage() {
|
||||||
const { data: clients } = useSuspenseQuery(clientsQuery());
|
const { data: clients } = useSuspenseQuery(clientsQuery());
|
||||||
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
||||||
const { data: groups } = useSuspenseQuery(groupsQuery());
|
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||||
|
const { group } = useSearch({ from: "/shell/clients" });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||||
const [editing, setEditing] = useState<Client | null>(null);
|
const [editing, setEditing] = useState<Client | null>(null);
|
||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||||
const readOnly = useReadOnlyConfig();
|
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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
<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>
|
||||||
|
)}
|
||||||
{clients.length === 0 ? (
|
{clients.length === 0 ? (
|
||||||
<p {...stylex.props(styles.empty)}>
|
<p {...stylex.props(styles.empty)}>
|
||||||
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||||
nothing to create by hand.
|
nothing to create by hand.
|
||||||
</p>
|
</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p {...stylex.props(styles.empty)}>No clients match this filter.</p>
|
||||||
) : (
|
) : (
|
||||||
<div {...stylex.props(shared.tableWrap)}>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<table {...stylex.props(styles.table)}>
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr {...stylex.props(styles.headRow)}>
|
<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)}>Name</th>
|
||||||
<th {...stylex.props(styles.cell)}>Group</th>
|
<th {...stylex.props(styles.cell)}>Group</th>
|
||||||
<th {...stylex.props(styles.cell)}>First seen</th>
|
<th {...stylex.props(styles.cell)}>First seen</th>
|
||||||
@@ -129,21 +160,19 @@ export default function ClientsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{clients.map((client) => (
|
{rows.map((client) => (
|
||||||
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
|
<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)}>
|
<td {...stylex.props(styles.cell)}>
|
||||||
{client.name !== "" ? (
|
<ClientDisplayName client={client} />
|
||||||
client.name
|
|
||||||
) : client.learned_name !== "" ? (
|
|
||||||
<span {...stylex.props(shared.learnedName)}>
|
|
||||||
{client.learned_name}
|
|
||||||
<span {...stylex.props(shared.learnedTag)}>learned</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span {...stylex.props(styles.dash)}>—</span>
|
|
||||||
)}
|
|
||||||
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
|
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(styles.cell)}>{client.group}</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.first_seen)}</td>
|
||||||
@@ -151,23 +180,32 @@ export default function ClientsPage() {
|
|||||||
<td {...stylex.props(styles.cell, styles.right)}>
|
<td {...stylex.props(styles.cell, styles.right)}>
|
||||||
{confirmingId === client.id ? (
|
{confirmingId === client.id ? (
|
||||||
<span {...stylex.props(styles.confirmGroup)}>
|
<span {...stylex.props(styles.confirmGroup)}>
|
||||||
|
{/* Authority is polled, so it can turn while a confirmation
|
||||||
|
sits open. The confirm path reads it on every render
|
||||||
|
rather than trusting the state that opened it. */}
|
||||||
<span {...stylex.props(styles.note)}>
|
<span {...stylex.props(styles.note)}>
|
||||||
Deleted clients re-materialize on their next DNS query.
|
{readOnly && client.hand_edited
|
||||||
|
? declaredDeleteNote(authority)
|
||||||
|
: "Deleted clients re-materialize on their next DNS query."}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{readOnly && client.hand_edited ? (
|
||||||
type="button"
|
<ConfigLockIndicator />
|
||||||
onClick={() => {
|
) : (
|
||||||
setConfirmingId(null);
|
<button
|
||||||
deleteMutation.mutate(client.id);
|
type="button"
|
||||||
}}
|
onClick={() => {
|
||||||
{...stylex.props(
|
setConfirmingId(null);
|
||||||
shared.smallButton,
|
deleteMutation.mutate(client.id);
|
||||||
styles.dangerText,
|
}}
|
||||||
shared.focusRing,
|
{...stylex.props(
|
||||||
)}
|
shared.smallButton,
|
||||||
>
|
styles.dangerText,
|
||||||
Confirm delete
|
shared.focusRing,
|
||||||
</button>
|
)}
|
||||||
|
>
|
||||||
|
Confirm delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmingId(null)}
|
onClick={() => setConfirmingId(null)}
|
||||||
@@ -178,26 +216,26 @@ export default function ClientsPage() {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span {...stylex.props(styles.actionGroup)}>
|
<span {...stylex.props(styles.actionGroup)}>
|
||||||
<button
|
{/* Naming a client writes configuration, so the affordance is
|
||||||
type="button"
|
absent — not disabled — wherever the write cannot land. */}
|
||||||
onClick={() => setEditing(client)}
|
{readOnly ? (
|
||||||
disabled={readOnly}
|
<ConfigLockIndicator />
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
) : (
|
||||||
{...stylex.props(
|
<button
|
||||||
shared.smallButton,
|
type="button"
|
||||||
styles.dimWhenDisabled,
|
onClick={() => setEditing(client)}
|
||||||
shared.focusRing,
|
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||||
)}
|
>
|
||||||
>
|
Edit
|
||||||
Edit
|
</button>
|
||||||
</button>
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmingId(client.id)}
|
onClick={() => setConfirmingId(client.id)}
|
||||||
disabled={readOnly && client.hand_edited}
|
disabled={readOnly && client.hand_edited}
|
||||||
title={
|
title={
|
||||||
readOnly && client.hand_edited
|
readOnly && client.hand_edited
|
||||||
? DECLARED_CLIENT_NOTE
|
? declaredDeleteNote(authority)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
{...stylex.props(
|
{...stylex.props(
|
||||||
@@ -220,7 +258,7 @@ export default function ClientsPage() {
|
|||||||
)}
|
)}
|
||||||
<InlineError error={deleteMutation.error} />
|
<InlineError error={deleteMutation.error} />
|
||||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||||
<PrefixesEditor prefixes={prefixes} groups={groups} />
|
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-18
@@ -6,10 +6,10 @@ import type { ClientPrefix, Group } from "@/lib/types";
|
|||||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import AuthorityGate from "@/features/configuration/AuthorityGate";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
prefixes: ClientPrefix[];
|
prefixes: ClientPrefix[];
|
||||||
@@ -25,6 +25,13 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.75rem",
|
lineHeight: "1.75rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
|
headingKey: {
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 400,
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
intro: {
|
intro: {
|
||||||
marginTop: "0.25rem",
|
marginTop: "0.25rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
@@ -80,16 +87,84 @@ const styles = stylex.create({
|
|||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
marginTop: "1rem",
|
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 queryClient = useQueryClient();
|
||||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||||
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||||
const [validation, setValidation] = useState<string | null>(null);
|
const [validation, setValidation] = useState<string | null>(null);
|
||||||
const dirty = isDirty(state);
|
const dirty = isDirty(state);
|
||||||
const fallbackGroupId = defaultGroupId(groups);
|
const fallbackGroupId = defaultGroupId(groups);
|
||||||
const readOnly = useReadOnlyConfig();
|
|
||||||
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
@@ -102,21 +177,16 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 ? (
|
{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)}>
|
<ul {...stylex.props(styles.rows)}>
|
||||||
{state.rows.map((row, index) => (
|
{state.rows.map((row, index) => (
|
||||||
<li key={index} {...stylex.props(styles.row)}>
|
<li key={index} {...stylex.props(styles.row)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
aria-label={`Prefix ${index + 1}`}
|
aria-label={`Range ${index + 1}`}
|
||||||
placeholder="192.168.1.0/24"
|
placeholder="192.168.1.0/24"
|
||||||
value={row.prefix}
|
value={row.prefix}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -125,7 +195,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
|
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
aria-label={`Group for prefix ${index + 1}`}
|
aria-label={`Group for range ${index + 1}`}
|
||||||
variant="inline"
|
variant="inline"
|
||||||
value={String(row.group_id)}
|
value={String(row.group_id)}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
@@ -136,7 +206,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
aria-label={`Priority for prefix ${index + 1}`}
|
aria-label={`Priority for range ${index + 1}`}
|
||||||
placeholder="100"
|
placeholder="100"
|
||||||
value={row.priority}
|
value={row.priority}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -167,16 +237,15 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
|
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
|
||||||
{...stylex.props(shared.button, shared.focusRing)}
|
{...stylex.props(shared.button, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Add prefix
|
Add range
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!dirty || mutation.isPending || readOnly}
|
disabled={!dirty || mutation.isPending}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Save prefixes
|
Save assignments
|
||||||
</button>
|
</button>
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<button
|
<button
|
||||||
@@ -191,6 +260,6 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
* with the same precedence the Clients page applies: a hand-typed `name` wins,
|
* with the same precedence the Clients page applies: a hand-typed `name` wins,
|
||||||
* the reverse-DNS `learned_name` stands in muted behind it, and an address with
|
* the reverse-DNS `learned_name` stands in muted behind it, and an address with
|
||||||
* neither — including one the loaded list has never seen — stays bare.
|
* neither — including one the loaded list has never seen — stays bare.
|
||||||
|
*
|
||||||
|
* The muted colour is the whole of the affordance here. The Clients page pairs
|
||||||
|
* it with an outlined "learned" tag, and keeps it: one mention per client is
|
||||||
|
* information. Repeating that tag down every row of a query table is noise, so
|
||||||
|
* the tables carry the name alone.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -32,18 +37,27 @@ export function useClientNames(): ClientNames {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
|
/**
|
||||||
|
* The name the loaded list gives this address right now, or null when it gives
|
||||||
|
* none. Callers that must distinguish "named" from "bare address" — rather than
|
||||||
|
* just render whichever applies — read this instead of re-deriving precedence.
|
||||||
|
*/
|
||||||
|
export function clientLabel(ip: string, names: ClientNames): { text: string; learned: boolean } | null {
|
||||||
const client = names.get(ip);
|
const client = names.get(ip);
|
||||||
if (client === undefined || (client.name === "" && client.learned_name === "")) {
|
if (client === undefined) return null;
|
||||||
return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
if (client.name !== "") return { text: client.name, learned: false };
|
||||||
}
|
if (client.learned_name !== "") return { text: client.learned_name, learned: true };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
|
||||||
|
const label = clientLabel(ip, names);
|
||||||
|
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
||||||
// The name replaces the address on screen, so the address stays reachable
|
// The name replaces the address on screen, so the address stays reachable
|
||||||
// as the tooltip rather than disappearing from the row entirely.
|
// as the tooltip rather than disappearing from the row entirely.
|
||||||
if (client.name !== "") return <span title={ip}>{client.name}</span>;
|
|
||||||
return (
|
return (
|
||||||
<span title={ip} {...stylex.props(shared.learnedName)}>
|
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
|
||||||
{client.learned_name}
|
{label.text}
|
||||||
<span {...stylex.props(shared.learnedTag)}>learned</span>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-4
@@ -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 () => {
|
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 rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
|
||||||
const { rerender } = render(
|
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
|
||||||
<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={rejecting} onCancel={undefined} />,
|
|
||||||
);
|
|
||||||
const url = screen.getByLabelText("URL") as HTMLInputElement;
|
const url = screen.getByLabelText("URL") as HTMLInputElement;
|
||||||
const name = screen.getByLabelText("Name") as HTMLInputElement;
|
const name = screen.getByLabelText("Name") as HTMLInputElement;
|
||||||
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
|
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");
|
expect(name.value).toBe("Example");
|
||||||
|
|
||||||
const resolving = vi.fn(() => Promise.resolve());
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
|
||||||
await waitFor(() => expect(url.value).toBe(""));
|
await waitFor(() => expect(url.value).toBe(""));
|
||||||
expect(name.value).toBe("");
|
expect(name.value).toBe("");
|
||||||
+2
-10
@@ -4,7 +4,6 @@ import { ApiError } from "@/lib/api";
|
|||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
form: {
|
form: {
|
||||||
@@ -56,14 +55,12 @@ export function swallowMutationError(error: unknown): void {
|
|||||||
interface BlocklistFormProps {
|
interface BlocklistFormProps {
|
||||||
initial?: Blocklist;
|
initial?: Blocklist;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
/** File authority: the server answers 403, so the submit stays down. */
|
|
||||||
readOnly: boolean;
|
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
onSubmit: (input: BlocklistInput) => Promise<void>;
|
onSubmit: (input: BlocklistInput) => Promise<void>;
|
||||||
onCancel?: () => 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 [url, setUrl] = useState(initial?.url ?? "");
|
||||||
const [name, setName] = useState(initial?.name ?? "");
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||||
@@ -122,12 +119,7 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
|
|||||||
Enabled
|
Enabled
|
||||||
</label>
|
</label>
|
||||||
<div {...stylex.props(styles.buttonRow)}>
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button
|
<button type="submit" disabled={busy} {...stylex.props(shared.primaryButton, shared.focusRing)}>
|
||||||
type="submit"
|
|
||||||
disabled={busy || readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
|
||||||
>
|
|
||||||
{initial === undefined ? "Add source" : "Save changes"}
|
{initial === undefined ? "Add source" : "Save changes"}
|
||||||
</button>
|
</button>
|
||||||
{onCancel !== undefined && (
|
{onCancel !== undefined && (
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
const lock = await screen.findByText("Locked");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(lock.getAttribute("aria-label")).toBe(
|
||||||
|
`Locked. Managed by ${CONFIG_PATH}; edit the file and restart nxdns.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unanswered status still locks, and says that is why", async () => {
|
||||||
|
renderIndicator("failed");
|
||||||
|
|
||||||
|
const lock = await screen.findByText("Locked");
|
||||||
|
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import { useAuthority } from "./authority";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export default function ConfigLockIndicator() {
|
||||||
|
const authority = useAuthority();
|
||||||
|
if (authority.state === "resolved" && authority.status.authority === "database") return null;
|
||||||
|
|
||||||
|
const reason =
|
||||||
|
authority.state === "pending"
|
||||||
|
? "Checking which configuration source this server obeys"
|
||||||
|
: authority.state === "failed"
|
||||||
|
? "Configuration status unavailable, so edits are held back"
|
||||||
|
: `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}>
|
||||||
|
Locked
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-5
@@ -7,7 +7,6 @@ import { sameSet, toggleSource } from "./sourceSet";
|
|||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
groupId: number;
|
groupId: number;
|
||||||
@@ -48,7 +47,6 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|||||||
const sources = useQuery(groupSourcesQuery(groupId));
|
const sources = useQuery(groupSourcesQuery(groupId));
|
||||||
const mutation = useMutation(groupSourcesPutMutation(queryClient));
|
const mutation = useMutation(groupSourcesPutMutation(queryClient));
|
||||||
const [selected, setSelected] = useState<number[] | null>(null);
|
const [selected, setSelected] = useState<number[] | null>(null);
|
||||||
const readOnly = useReadOnlyConfig();
|
|
||||||
|
|
||||||
if (sources.isPending) {
|
if (sources.isPending) {
|
||||||
return (
|
return (
|
||||||
@@ -60,7 +58,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|||||||
if (sources.isError) return <InlineError error={sources.error} />;
|
if (sources.isError) return <InlineError error={sources.error} />;
|
||||||
|
|
||||||
if (blocklists.length === 0) {
|
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;
|
const current = selected ?? sources.data;
|
||||||
@@ -87,8 +85,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|||||||
<div {...stylex.props(styles.buttonRow)}>
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!dirty || mutation.isPending || readOnly}
|
disabled={!dirty || mutation.isPending}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
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,596 @@
|
|||||||
|
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 { 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",
|
||||||
|
},
|
||||||
|
checkboxLabel: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
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)}>
|
||||||
|
<label {...stylex.props(styles.checkboxLabel)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={group.safe_search}
|
||||||
|
disabled={update.isPending}
|
||||||
|
{...stylex.props(shared.focusRing)}
|
||||||
|
onChange={(event) =>
|
||||||
|
update.mutate({
|
||||||
|
id: group.id,
|
||||||
|
input: { name: group.name, safe_search: event.target.checked },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Safe search
|
||||||
|
</label>
|
||||||
|
<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,295 @@
|
|||||||
|
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("checkbox", { 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("checkbox", { 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("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();
|
||||||
|
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
|
||||||
|
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
|
||||||
|
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+148
-91
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
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 * as stylex from "@stylexjs/stylex";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
@@ -11,48 +11,23 @@ import {
|
|||||||
blocklistsUpdateNowMutation,
|
blocklistsUpdateNowMutation,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
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 ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
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({
|
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: {
|
name: {
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
@@ -82,9 +57,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 queryClient = useQueryClient();
|
||||||
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
|
|
||||||
const [editing, setEditing] = useState<Blocklist | null>(null);
|
const [editing, setEditing] = useState<Blocklist | null>(null);
|
||||||
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
|
||||||
|
|
||||||
@@ -92,13 +187,6 @@ export default function BlocklistsPage() {
|
|||||||
const save = useMutation(blocklistUpdateMutation(queryClient));
|
const save = useMutation(blocklistUpdateMutation(queryClient));
|
||||||
const toggle = useMutation(blocklistUpdateMutation(queryClient));
|
const toggle = useMutation(blocklistUpdateMutation(queryClient));
|
||||||
const remove = useMutation(blocklistDeleteMutation(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) {
|
async function submitForm(input: BlocklistInput) {
|
||||||
if (editing === null) {
|
if (editing === null) {
|
||||||
@@ -126,30 +214,12 @@ export default function BlocklistsPage() {
|
|||||||
const tableError = remove.error ?? toggle.error;
|
const tableError = remove.error ?? toggle.error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<div>
|
||||||
<div {...stylex.props(styles.header)}>
|
|
||||||
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => updateNow.mutate()}
|
|
||||||
disabled={updateNow.isPending}
|
|
||||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
|
||||||
>
|
|
||||||
{updateNow.isPending ? "Updating…" : "Update now"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{updateNow.isSuccess && !updateNow.isPending && (
|
|
||||||
<p {...stylex.props(styles.done)} role="status">
|
|
||||||
Update completed; source status refreshed below.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<InlineError error={updateNow.error} />
|
|
||||||
|
|
||||||
{blocklists.length === 0 ? (
|
{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)}>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<table {...stylex.props(styles.table)}>
|
<table {...stylex.props(config.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>Name</th>
|
<th {...stylex.props(shared.th)}>Name</th>
|
||||||
@@ -183,8 +253,7 @@ export default function BlocklistsPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`${b.name} enabled`}
|
aria-label={`${b.name} enabled`}
|
||||||
checked={b.enabled}
|
checked={b.enabled}
|
||||||
disabled={toggle.isPending || readOnly}
|
disabled={toggle.isPending}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
onChange={() => toggleEnabled(b)}
|
onChange={() => toggleEnabled(b)}
|
||||||
{...stylex.props(shared.focusRing)}
|
{...stylex.props(shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
@@ -204,22 +273,19 @@ export default function BlocklistsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(b)}
|
onClick={() => setEditing(b)}
|
||||||
disabled={readOnly}
|
{...stylex.props(shared.linkButton, shared.focusRing)}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(
|
|
||||||
shared.linkButton,
|
|
||||||
styles.dimWhenDisabled,
|
|
||||||
shared.focusRing,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPendingDelete(b)}
|
onClick={() => setPendingDelete(b)}
|
||||||
disabled={remove.isPending || readOnly}
|
disabled={remove.isPending}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
{...stylex.props(
|
||||||
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
shared.dangerLinkButton,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -229,13 +295,7 @@ export default function BlocklistsPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p {...stylex.props(styles.note)}>
|
<p {...stylex.props(config.note)}>{SKIPPED_NOTE}</p>
|
||||||
Both “Skipped” columns count lines nxdns read and did not take. Skipped regex lines are patterns
|
|
||||||
nxdns accepts only from you — adopt one you trust as a regex rule. Skipped unsupported lines are
|
|
||||||
syntax nxdns cannot translate into a DNS decision: cosmetic element hiding, browser-only
|
|
||||||
modifiers. A skipped unsupported count that dwarfs the domain count usually means the list is
|
|
||||||
written for a browser extension, and its DNS or hosts variant will block more here.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<InlineError error={tableError} />
|
<InlineError error={tableError} />
|
||||||
@@ -244,14 +304,11 @@ export default function BlocklistsPage() {
|
|||||||
key={editing?.id ?? "add"}
|
key={editing?.id ?? "add"}
|
||||||
initial={editing ?? undefined}
|
initial={editing ?? undefined}
|
||||||
busy={editing === null ? create.isPending : save.isPending}
|
busy={editing === null ? create.isPending : save.isPending}
|
||||||
readOnly={readOnly}
|
|
||||||
error={formError}
|
error={formError}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SourceStatusSection sources={sources} namesById={namesById} />
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={pendingDelete !== null}
|
isOpen={pendingDelete !== null}
|
||||||
title="Delete blocklist"
|
title="Delete blocklist"
|
||||||
@@ -264,6 +321,6 @@ export default function BlocklistsPage() {
|
|||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
onCancel={() => setPendingDelete(null)}
|
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)}</>;
|
||||||
|
}
|
||||||
+87
-103
@@ -1,5 +1,5 @@
|
|||||||
import { useId, useState, type FormEvent } from "react";
|
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 * as stylex from "@stylexjs/stylex";
|
||||||
import {
|
import {
|
||||||
localRecordCreateMutation,
|
localRecordCreateMutation,
|
||||||
@@ -12,9 +12,9 @@ import InlineError from "@/lib/InlineError";
|
|||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
import { useCrudForm } from "@/ui/useCrudForm";
|
import { useCrudForm } from "@/ui/useCrudForm";
|
||||||
|
import QueryPanel from "./QueryPanel";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
|
||||||
|
|
||||||
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
||||||
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
|
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
|
||||||
@@ -93,14 +93,12 @@ const styles = stylex.create({
|
|||||||
function RecordForm({
|
function RecordForm({
|
||||||
initial,
|
initial,
|
||||||
busy,
|
busy,
|
||||||
readOnly,
|
|
||||||
error,
|
error,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
initial?: LocalRecord;
|
initial?: LocalRecord;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
readOnly: boolean;
|
|
||||||
error: unknown;
|
error: unknown;
|
||||||
onSubmit: (input: LocalRecordInput) => void;
|
onSubmit: (input: LocalRecordInput) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -174,12 +172,7 @@ function RecordForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div {...stylex.props(styles.buttonRow)}>
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button
|
<button type="submit" disabled={busy} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}>
|
||||||
type="submit"
|
|
||||||
disabled={busy || readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
|
||||||
>
|
|
||||||
{busy ? "Saving…" : "Save"}
|
{busy ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
||||||
@@ -192,7 +185,7 @@ function RecordForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RecordsTab() {
|
export default function RecordsTab() {
|
||||||
const records = useSuspenseQuery(localRecordsQuery()).data;
|
const query = useQuery(localRecordsQuery());
|
||||||
const {
|
const {
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
@@ -211,7 +204,6 @@ export default function RecordsTab() {
|
|||||||
remove: localRecordDeleteMutation,
|
remove: localRecordDeleteMutation,
|
||||||
confirmDelete: (record) => `Delete record "${record.name}"?`,
|
confirmDelete: (record) => `Delete record "${record.name}"?`,
|
||||||
});
|
});
|
||||||
const readOnly = useReadOnlyConfig();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -220,8 +212,6 @@ export default function RecordsTab() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openForm({ mode: "create" })}
|
onClick={() => openForm({ mode: "create" })}
|
||||||
disabled={readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Add record
|
Add record
|
||||||
@@ -229,98 +219,92 @@ export default function RecordsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<InlineError error={remove.error} />
|
<InlineError error={remove.error} />
|
||||||
{form?.mode === "create" && (
|
{form?.mode === "create" && (
|
||||||
<RecordForm
|
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||||
busy={create.isPending}
|
|
||||||
readOnly={readOnly}
|
|
||||||
error={create.error}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
onCancel={closeForm}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div {...stylex.props(shared.tableWrap)}>
|
<QueryPanel query={query}>
|
||||||
<table {...stylex.props(styles.table)}>
|
{(records) => (
|
||||||
<thead>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<tr {...stylex.props(styles.headRow)}>
|
<table {...stylex.props(styles.table)}>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
<thead>
|
||||||
Name
|
<tr {...stylex.props(styles.headRow)}>
|
||||||
</th>
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
Name
|
||||||
Type
|
</th>
|
||||||
</th>
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
Type
|
||||||
Value
|
</th>
|
||||||
</th>
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
Value
|
||||||
TTL
|
</th>
|
||||||
</th>
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
TTL
|
||||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
</th>
|
||||||
</th>
|
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||||
</tr>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</thead>
|
</th>
|
||||||
<tbody>
|
</tr>
|
||||||
{records.length === 0 && (
|
</thead>
|
||||||
<tr>
|
<tbody>
|
||||||
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
|
{records.length === 0 && (
|
||||||
No local records yet.
|
<tr>
|
||||||
</td>
|
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
|
||||||
</tr>
|
No local records yet.
|
||||||
)}
|
|
||||||
{records.map((record) => (
|
|
||||||
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
|
|
||||||
{form?.mode === "edit" && form.entity.id === record.id ? (
|
|
||||||
<td colSpan={5}>
|
|
||||||
<RecordForm
|
|
||||||
initial={record}
|
|
||||||
busy={update.isPending}
|
|
||||||
readOnly={readOnly}
|
|
||||||
error={update.error}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
onCancel={closeForm}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
|
|
||||||
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
|
|
||||||
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
|
|
||||||
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
|
|
||||||
<td {...stylex.props(styles.actionCell)}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openForm({ mode: "edit", entity: record })}
|
|
||||||
disabled={readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(
|
|
||||||
shared.rowButton,
|
|
||||||
styles.dimWhenDisabled,
|
|
||||||
shared.focusRing,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDelete(record)}
|
|
||||||
disabled={remove.isPending || readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(
|
|
||||||
shared.rowButton,
|
|
||||||
styles.dangerText,
|
|
||||||
styles.dimWhenDisabled,
|
|
||||||
shared.focusRing,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tr>
|
{records.map((record) => (
|
||||||
))}
|
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
|
||||||
</tbody>
|
{form?.mode === "edit" && form.entity.id === record.id ? (
|
||||||
</table>
|
<td colSpan={5}>
|
||||||
</div>
|
<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
|
<ConfirmDialog
|
||||||
isOpen={pendingDelete !== null}
|
isOpen={pendingDelete !== null}
|
||||||
title="Delete record"
|
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,259 @@
|
|||||||
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { DATABASE, 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(/takes effect at the next restart/)).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 re-reads the config status, and the shell states the pending restart", async () => {
|
||||||
|
// The client never decides a restart is owed: the server sets the flag, and
|
||||||
|
// the mutation's invalidation is only what makes the page ask again.
|
||||||
|
let restartPending = false;
|
||||||
|
await openResolution(undefined, {
|
||||||
|
responses: { "GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }) },
|
||||||
|
onWrite: () => {
|
||||||
|
restartPending = true;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await screen.findByRole("heading", { name: "Add upstream" });
|
||||||
|
expect(screen.queryByText(/Restart nxdns to apply them/)).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
|
||||||
|
|
||||||
|
await screen.findByText(/Saved changes are not running yet\. Restart nxdns to apply them\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,331 @@
|
|||||||
|
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)";
|
||||||
|
|
||||||
|
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: {
|
||||||
|
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;
|
||||||
|
return (
|
||||||
|
<div {...stylex.props(styles.field)}>
|
||||||
|
<FieldLabel id={id} text={def.key} restart={restart} />
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
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 {...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,327 @@
|
|||||||
|
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, 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// `logging.level` is enum-backed, so the list covers both field renderings: an
|
||||||
|
// input whose label carries the mark, and a `Select` that cannot.
|
||||||
|
const RESTART_KEYS = ["dns.port", "web.port", "logging.level"];
|
||||||
|
|
||||||
|
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 settings.zig: a patch touching only `web.password` applies live. */
|
||||||
|
function needsRestart(patch: SettingsPatch): boolean {
|
||||||
|
return Object.entries(patch).some(([section, fields]) =>
|
||||||
|
Object.keys(fields as Record<string, unknown>).some((key) => !(section === "web" && key === "password")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stored = baseSettings();
|
||||||
|
putBodies = [];
|
||||||
|
putResponse = null;
|
||||||
|
restartPending = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openSystem() {
|
||||||
|
stubApi(DATABASE, {
|
||||||
|
responses: {
|
||||||
|
"GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }),
|
||||||
|
"GET /api/settings": () => ({ settings: stored, restart_required: RESTART_KEYS }),
|
||||||
|
},
|
||||||
|
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)) restartPending = true;
|
||||||
|
return json({ settings: stored, restart_required: RESTART_KEYS });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
|
||||||
|
expect(within(screen.getByRole("group", { name: "DNS" })).getByText("needs restart")).toBeTruthy();
|
||||||
|
// `rate_limit` is not on the list, so it carries no mark.
|
||||||
|
const cache = screen.getByRole("group", { name: "Cache" });
|
||||||
|
expect(within(cache).queryByText("needs restart")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
|
||||||
|
await openSystem();
|
||||||
|
|
||||||
|
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 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing a number field disables Save instead of sending NaN", async () => {
|
||||||
|
await openSystem();
|
||||||
|
|
||||||
|
const cache = screen.getByRole("group", { name: "Cache" });
|
||||||
|
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 mixed patch makes the server owe a restart, and the shell says so", 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 screen.findByText(/Saved changes are not running yet/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-10
@@ -4,15 +4,12 @@ import InlineError from "@/lib/InlineError";
|
|||||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
|
||||||
|
|
||||||
const DEFAULT_PRIORITY = "100";
|
const DEFAULT_PRIORITY = "100";
|
||||||
|
|
||||||
interface UpstreamFormProps {
|
interface UpstreamFormProps {
|
||||||
initial?: Upstream;
|
initial?: Upstream;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
/** File authority: the server answers 403, so the submit stays down. */
|
|
||||||
readOnly: boolean;
|
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
onSubmit: (input: UpstreamInput) => Promise<void>;
|
onSubmit: (input: UpstreamInput) => Promise<void>;
|
||||||
onCancel?: () => 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 [url, setUrl] = useState(initial?.url ?? "");
|
||||||
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
|
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
|
||||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||||
@@ -145,12 +142,7 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
|
|||||||
Enabled
|
Enabled
|
||||||
</label>
|
</label>
|
||||||
<div {...stylex.props(styles.actions)}>
|
<div {...stylex.props(styles.actions)}>
|
||||||
<button
|
<button type="submit" disabled={busy} {...stylex.props(shared.primaryButton, shared.focusRing)}>
|
||||||
type="submit"
|
|
||||||
disabled={busy || readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
|
||||||
>
|
|
||||||
{initial === undefined ? "Add upstream" : "Save changes"}
|
{initial === undefined ? "Add upstream" : "Save changes"}
|
||||||
</button>
|
</button>
|
||||||
{onCancel !== undefined && (
|
{onCancel !== undefined && (
|
||||||
+95
-62
@@ -1,40 +1,21 @@
|
|||||||
import { useState } from "react";
|
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 * as stylex from "@stylexjs/stylex";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
|
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
|
||||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||||
import { raiseRestartBanner } from "../settings/restartBanner";
|
|
||||||
import UpstreamForm from "./UpstreamForm";
|
|
||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
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 builds its clients at startup, so an edit here takes effect at the next restart.";
|
||||||
|
|
||||||
const styles = stylex.create({
|
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: {
|
url: {
|
||||||
display: "block",
|
display: "block",
|
||||||
maxWidth: "18rem",
|
maxWidth: "18rem",
|
||||||
@@ -50,11 +31,79 @@ const styles = stylex.create({
|
|||||||
dimWhenDisabled: {
|
dimWhenDisabled: {
|
||||||
opacity: { default: 1, ":disabled": 0.5 },
|
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 queryClient = useQueryClient();
|
||||||
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
|
|
||||||
const [editing, setEditing] = useState<Upstream | null>(null);
|
const [editing, setEditing] = useState<Upstream | null>(null);
|
||||||
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
|
||||||
|
|
||||||
@@ -62,7 +111,6 @@ export default function UpstreamsPage() {
|
|||||||
const save = useMutation(upstreamUpdateMutation(queryClient));
|
const save = useMutation(upstreamUpdateMutation(queryClient));
|
||||||
const toggle = useMutation(upstreamUpdateMutation(queryClient));
|
const toggle = useMutation(upstreamUpdateMutation(queryClient));
|
||||||
const remove = useMutation(upstreamDeleteMutation(queryClient));
|
const remove = useMutation(upstreamDeleteMutation(queryClient));
|
||||||
const readOnly = useReadOnlyConfig();
|
|
||||||
|
|
||||||
async function submitForm(input: UpstreamInput) {
|
async function submitForm(input: UpstreamInput) {
|
||||||
if (editing === null) {
|
if (editing === null) {
|
||||||
@@ -71,22 +119,18 @@ export default function UpstreamsPage() {
|
|||||||
await save.mutateAsync({ id: editing.id, input });
|
await save.mutateAsync({ id: editing.id, input });
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
}
|
}
|
||||||
raiseRestartBanner();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEnabled(u: Upstream) {
|
function toggleEnabled(u: Upstream) {
|
||||||
toggle.mutate(
|
toggle.mutate({
|
||||||
{
|
id: u.id,
|
||||||
id: u.id,
|
input: { url: u.url, priority: u.priority, enabled: !u.enabled, tls_name: u.tls_name },
|
||||||
input: { url: u.url, priority: u.priority, enabled: !u.enabled, tls_name: u.tls_name },
|
});
|
||||||
},
|
|
||||||
{ onSuccess: () => raiseRestartBanner() },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
if (pendingDelete === null) return;
|
if (pendingDelete === null) return;
|
||||||
remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
|
remove.mutate(pendingDelete.id);
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,18 +138,12 @@ export default function UpstreamsPage() {
|
|||||||
const tableError = remove.error ?? toggle.error;
|
const tableError = remove.error ?? toggle.error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{upstreams.length === 0 ? (
|
{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)}>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<table {...stylex.props(styles.table)}>
|
<table {...stylex.props(config.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th {...stylex.props(shared.th)}>URL</th>
|
<th {...stylex.props(shared.th)}>URL</th>
|
||||||
@@ -131,8 +169,7 @@ export default function UpstreamsPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`${u.url} enabled`}
|
aria-label={`${u.url} enabled`}
|
||||||
checked={u.enabled}
|
checked={u.enabled}
|
||||||
disabled={toggle.isPending || readOnly}
|
disabled={toggle.isPending}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
onChange={() => toggleEnabled(u)}
|
onChange={() => toggleEnabled(u)}
|
||||||
{...stylex.props(shared.focusRing)}
|
{...stylex.props(shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
@@ -143,22 +180,19 @@ export default function UpstreamsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(u)}
|
onClick={() => setEditing(u)}
|
||||||
disabled={readOnly}
|
{...stylex.props(shared.linkButton, shared.focusRing)}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(
|
|
||||||
shared.linkButton,
|
|
||||||
styles.dimWhenDisabled,
|
|
||||||
shared.focusRing,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPendingDelete(u)}
|
onClick={() => setPendingDelete(u)}
|
||||||
disabled={remove.isPending || readOnly}
|
disabled={remove.isPending}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
{...stylex.props(
|
||||||
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
shared.dangerLinkButton,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -176,7 +210,6 @@ export default function UpstreamsPage() {
|
|||||||
key={editing?.id ?? "add"}
|
key={editing?.id ?? "add"}
|
||||||
initial={editing ?? undefined}
|
initial={editing ?? undefined}
|
||||||
busy={editing === null ? create.isPending : save.isPending}
|
busy={editing === null ? create.isPending : save.isPending}
|
||||||
readOnly={readOnly}
|
|
||||||
error={formError}
|
error={formError}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||||
@@ -194,6 +227,6 @@ export default function UpstreamsPage() {
|
|||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+79
-95
@@ -1,5 +1,5 @@
|
|||||||
import { useId, useState, type FormEvent } from "react";
|
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 * as stylex from "@stylexjs/stylex";
|
||||||
import {
|
import {
|
||||||
forwardZoneCreateMutation,
|
forwardZoneCreateMutation,
|
||||||
@@ -11,9 +11,9 @@ import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
|
|||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import { useCrudForm } from "@/ui/useCrudForm";
|
import { useCrudForm } from "@/ui/useCrudForm";
|
||||||
|
import QueryPanel from "./QueryPanel";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
formHeading: {
|
formHeading: {
|
||||||
@@ -89,14 +89,12 @@ const styles = stylex.create({
|
|||||||
function ZoneForm({
|
function ZoneForm({
|
||||||
initial,
|
initial,
|
||||||
busy,
|
busy,
|
||||||
readOnly,
|
|
||||||
error,
|
error,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
initial?: ForwardZone;
|
initial?: ForwardZone;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
readOnly: boolean;
|
|
||||||
error: unknown;
|
error: unknown;
|
||||||
onSubmit: (input: ForwardZoneInput) => void;
|
onSubmit: (input: ForwardZoneInput) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -142,12 +140,7 @@ function ZoneForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div {...stylex.props(styles.buttonRow)}>
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button
|
<button type="submit" disabled={busy} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}>
|
||||||
type="submit"
|
|
||||||
disabled={busy || readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
|
||||||
>
|
|
||||||
{busy ? "Saving…" : "Save"}
|
{busy ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
||||||
@@ -160,7 +153,7 @@ function ZoneForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ZonesTab() {
|
export default function ZonesTab() {
|
||||||
const zones = useSuspenseQuery(forwardZonesQuery()).data;
|
const query = useQuery(forwardZonesQuery());
|
||||||
const {
|
const {
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
@@ -179,7 +172,6 @@ export default function ZonesTab() {
|
|||||||
remove: forwardZoneDeleteMutation,
|
remove: forwardZoneDeleteMutation,
|
||||||
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
|
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
|
||||||
});
|
});
|
||||||
const readOnly = useReadOnlyConfig();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -190,8 +182,6 @@ export default function ZonesTab() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openForm({ mode: "create" })}
|
onClick={() => openForm({ mode: "create" })}
|
||||||
disabled={readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Add zone
|
Add zone
|
||||||
@@ -199,90 +189,84 @@ export default function ZonesTab() {
|
|||||||
</div>
|
</div>
|
||||||
<InlineError error={remove.error} />
|
<InlineError error={remove.error} />
|
||||||
{form?.mode === "create" && (
|
{form?.mode === "create" && (
|
||||||
<ZoneForm
|
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
|
||||||
busy={create.isPending}
|
|
||||||
readOnly={readOnly}
|
|
||||||
error={create.error}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
onCancel={closeForm}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div {...stylex.props(shared.tableWrap)}>
|
<QueryPanel query={query}>
|
||||||
<table {...stylex.props(styles.table)}>
|
{(zones) => (
|
||||||
<thead>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<tr {...stylex.props(styles.headRow)}>
|
<table {...stylex.props(styles.table)}>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
<thead>
|
||||||
Zone
|
<tr {...stylex.props(styles.headRow)}>
|
||||||
</th>
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
Zone
|
||||||
Resolver
|
</th>
|
||||||
</th>
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
Resolver
|
||||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
</th>
|
||||||
</th>
|
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||||
</tr>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</thead>
|
</th>
|
||||||
<tbody>
|
</tr>
|
||||||
{zones.length === 0 && (
|
</thead>
|
||||||
<tr>
|
<tbody>
|
||||||
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
|
{zones.length === 0 && (
|
||||||
No forward zones yet.
|
<tr>
|
||||||
</td>
|
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
|
||||||
</tr>
|
No forward zones yet.
|
||||||
)}
|
|
||||||
{zones.map((zone) => (
|
|
||||||
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
|
|
||||||
{form?.mode === "edit" && form.entity.id === zone.id ? (
|
|
||||||
<td colSpan={3}>
|
|
||||||
<ZoneForm
|
|
||||||
initial={zone}
|
|
||||||
busy={update.isPending}
|
|
||||||
readOnly={readOnly}
|
|
||||||
error={update.error}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
onCancel={closeForm}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
|
|
||||||
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
|
|
||||||
<td {...stylex.props(styles.actionCell)}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openForm({ mode: "edit", entity: zone })}
|
|
||||||
disabled={readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(
|
|
||||||
shared.rowButton,
|
|
||||||
styles.dimWhenDisabled,
|
|
||||||
shared.focusRing,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDelete(zone)}
|
|
||||||
disabled={remove.isPending || readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
{...stylex.props(
|
|
||||||
shared.rowButton,
|
|
||||||
styles.dangerText,
|
|
||||||
styles.dimWhenDisabled,
|
|
||||||
shared.focusRing,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tr>
|
{zones.map((zone) => (
|
||||||
))}
|
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
|
||||||
</tbody>
|
{form?.mode === "edit" && form.entity.id === zone.id ? (
|
||||||
</table>
|
<td colSpan={3}>
|
||||||
</div>
|
<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
|
<ConfirmDialog
|
||||||
isOpen={pendingDelete !== null}
|
isOpen={pendingDelete !== null}
|
||||||
title="Delete forward zone"
|
title="Delete forward zone"
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
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"], [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/stats?period=24h": {
|
||||||
|
period: "24h",
|
||||||
|
since: 0,
|
||||||
|
until: 86400,
|
||||||
|
queries: 0,
|
||||||
|
blocked: 0,
|
||||||
|
clients: 0,
|
||||||
|
avg_response_time_us: null,
|
||||||
|
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,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,264 @@
|
|||||||
|
/**
|
||||||
|
* 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 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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: ["dns.port", "web.port"] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,189 +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";
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
"/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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"/api/stats?period=1h": {
|
|
||||||
period: "1h",
|
|
||||||
since: 0,
|
|
||||||
until: 3600,
|
|
||||||
queries: 12,
|
|
||||||
blocked: 3,
|
|
||||||
cached: 0,
|
|
||||||
clients: 2,
|
|
||||||
avg_response_time_us: null,
|
|
||||||
},
|
|
||||||
"/api/stats/timeseries?period=1h": {
|
|
||||||
period: "1h",
|
|
||||||
since: 0,
|
|
||||||
until: 3600,
|
|
||||||
bucket_seconds: 60,
|
|
||||||
buckets: [],
|
|
||||||
},
|
|
||||||
"/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,
|
|
||||||
},
|
|
||||||
"/api/upstream/health": {
|
|
||||||
upstreams: [
|
|
||||||
{
|
|
||||||
url: "https://dns.example/dns-query",
|
|
||||||
enabled: true,
|
|
||||||
available: false,
|
|
||||||
consecutive_failures: 4,
|
|
||||||
total_successes: 90,
|
|
||||||
total_failures: 10,
|
|
||||||
success_rate: 0.9,
|
|
||||||
last_error: "timeout",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "udp://9.9.9.9:53",
|
|
||||||
enabled: true,
|
|
||||||
available: true,
|
|
||||||
consecutive_failures: 0,
|
|
||||||
total_successes: 100,
|
|
||||||
total_failures: 0,
|
|
||||||
success_rate: 1,
|
|
||||||
last_error: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
available: 1,
|
|
||||||
total: 2,
|
|
||||||
},
|
|
||||||
"/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("Disk")).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("timeout")).toBeTruthy();
|
|
||||||
expect(screen.getByText("1/2 available")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
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("Disk")).toBeTruthy();
|
|
||||||
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
|
|
||||||
});
|
|
||||||
@@ -1,168 +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 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 disk 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());
|
|
||||||
|
|
||||||
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} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +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)}>
|
|
||||||
<h2 {...stylex.props(styles.heading)}>
|
|
||||||
Disk
|
|
||||||
<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,156 +0,0 @@
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
|
||||||
import type { UpstreamHealth } from "@/lib/types";
|
|
||||||
import { styles as shared } from "@/ui/styles";
|
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
tableWrap: {
|
|
||||||
overflowX: "auto",
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
marginTop: "0.5rem",
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
lineHeight: "1.25rem",
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
|
|
||||||
const bad = value === badValue;
|
|
||||||
return <span {...stylex.props(bad && styles.bad)}>{value ? "yes" : "no"}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
|
|
||||||
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.headRow)}>
|
|
||||||
<th scope="col" {...stylex.props(styles.th)}>
|
|
||||||
URL
|
|
||||||
</th>
|
|
||||||
<th scope="col" {...stylex.props(styles.th)}>
|
|
||||||
Enabled
|
|
||||||
</th>
|
|
||||||
<th scope="col" {...stylex.props(styles.th)}>
|
|
||||||
Available
|
|
||||||
</th>
|
|
||||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
|
||||||
Failures
|
|
||||||
</th>
|
|
||||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
|
||||||
Success rate
|
|
||||||
</th>
|
|
||||||
<th scope="col" {...stylex.props(styles.th)}>
|
|
||||||
Last error
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{health.upstreams.map((upstream) => (
|
|
||||||
<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)}>
|
|
||||||
<YesNo value={upstream.enabled} badValue={false} />
|
|
||||||
</td>
|
|
||||||
<td {...stylex.props(styles.cell)}>
|
|
||||||
<YesNo value={upstream.available} badValue={false} />
|
|
||||||
</td>
|
|
||||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
|
||||||
{upstream.total_failures}
|
|
||||||
</td>
|
|
||||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
|
||||||
{(upstream.success_rate * 100).toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
|
|
||||||
{upstream.last_error || "—"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
import { DIAGNOSTIC_CODES, type DiagnosticEvent } from "@/lib/types";
|
||||||
|
import { health } from "@/lib/healthFixture";
|
||||||
|
import { EVENT_COPY } from "./eventCopy";
|
||||||
|
|
||||||
|
const NOW_S = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
function event(overrides: Partial<DiagnosticEvent> = {}): DiagnosticEvent {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
code: "blocklist.refresh",
|
||||||
|
component: "blocklist",
|
||||||
|
subject: "StevenBlack",
|
||||||
|
severity: "warning",
|
||||||
|
first_seen: NOW_S - 7200,
|
||||||
|
last_seen: NOW_S - 600,
|
||||||
|
occurrences: 4,
|
||||||
|
resolved_at: null,
|
||||||
|
detail: "download failed: ConnectionTimedOut",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */
|
||||||
|
const NO_CONTENT = Symbol("204");
|
||||||
|
|
||||||
|
let responses: Record<string, unknown>;
|
||||||
|
let requested: string[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requested = [];
|
||||||
|
responses = {
|
||||||
|
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||||
|
// The shell reads health for the Diagnostics nav badge on every route.
|
||||||
|
"/api/health": health(),
|
||||||
|
};
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
const key = method === "GET" ? url : `${method} ${url}`;
|
||||||
|
requested.push(key);
|
||||||
|
const payload = responses[key];
|
||||||
|
if (payload === undefined)
|
||||||
|
return new Response(JSON.stringify({ error: "no such event" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
if (payload === NO_CONTENT) return new Response(null, { status: 204 });
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `retry` is off in the failure test: the shared client backs 5xx off for
|
||||||
|
* seconds, which the render assertions would sit through for nothing.
|
||||||
|
*/
|
||||||
|
function renderDetail(id: number, { retry = true } = {}) {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
if (!retry) {
|
||||||
|
const defaults = queryClient.getDefaultOptions();
|
||||||
|
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
|
||||||
|
}
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: [`/diagnostics/${id}`] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an open episode shows its facts, its copy and the error the server sent", async () => {
|
||||||
|
responses["/api/diagnostics/42"] = event();
|
||||||
|
renderDetail(42);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||||
|
expect(screen.getByText("Warning")).toBeTruthy();
|
||||||
|
expect(screen.getByText("StevenBlack")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Active for 2h")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Not yet — still failing")).toBeTruthy();
|
||||||
|
expect(screen.getByText("4")).toBeTruthy();
|
||||||
|
expect(screen.getByText("blocklist.refresh")).toBeTruthy();
|
||||||
|
expect(screen.getByText(EVENT_COPY["blocklist.refresh"].impact)).toBeTruthy();
|
||||||
|
expect(screen.getByText(EVENT_COPY["blocklist.refresh"].remediation)).toBeTruthy();
|
||||||
|
expect(screen.getByText("download failed: ConnectionTimedOut")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("link", { name: "Go to 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 () => {
|
||||||
|
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
|
||||||
|
renderDetail(7);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||||
|
expect(screen.getByText("Resolved after 1h")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Not yet — still failing")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an open episode offers no purge", async () => {
|
||||||
|
responses["/api/diagnostics/42"] = event();
|
||||||
|
renderDetail(42);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||||
|
expect(screen.queryByRole("button", { name: "Purge" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("purging a resolved episode asks first, then returns to the list", async () => {
|
||||||
|
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
|
||||||
|
responses["DELETE /api/diagnostics/7"] = NO_CONTENT;
|
||||||
|
responses["/api/diagnostics?state=active"] = { events: [], next_before: null, active: { warnings: 0, errors: 0 } };
|
||||||
|
responses["/api/diagnostics?state=resolved"] = {
|
||||||
|
events: [],
|
||||||
|
next_before: null,
|
||||||
|
active: { warnings: 0, errors: 0 },
|
||||||
|
};
|
||||||
|
const router = renderDetail(7);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good.");
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(requested).not.toContain("DELETE /api/diagnostics/7");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
|
||||||
|
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/7"));
|
||||||
|
// The row it was showing no longer exists, so the page it navigates to is
|
||||||
|
// the list rather than a 404 of its own.
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe("/diagnostics"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a refused purge stays on the event and shows why", async () => {
|
||||||
|
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
|
||||||
|
if (init?.method === "DELETE")
|
||||||
|
return new Response(JSON.stringify({ error: "the event is still active" }), {
|
||||||
|
status: 409,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify(responses[String(input)] ?? {}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const router = renderDetail(7, { retry: false });
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
|
||||||
|
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toContain("the event is still active");
|
||||||
|
expect(router.state.location.pathname).toBe("/diagnostics/7");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every code renders its own title, impact and remediation", async () => {
|
||||||
|
for (const [index, code] of DIAGNOSTIC_CODES.entries()) {
|
||||||
|
const id = 100 + index;
|
||||||
|
responses[`/api/diagnostics/${id}`] = event({ id, code, component: code.slice(0, code.indexOf(".")) });
|
||||||
|
renderDetail(id);
|
||||||
|
|
||||||
|
const copy = EVENT_COPY[code];
|
||||||
|
await screen.findByRole("heading", { name: copy.title });
|
||||||
|
expect(screen.getByText(copy.impact), code).toBeTruthy();
|
||||||
|
expect(screen.getByText(copy.remediation), code).toBeTruthy();
|
||||||
|
screen.getByText(code);
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an event retention has removed shows the server's message, not an empty page", async () => {
|
||||||
|
renderDetail(999);
|
||||||
|
await screen.findByText("no such event");
|
||||||
|
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unavailable store reports the failure instead of loading forever", async () => {
|
||||||
|
responses["/api/diagnostics/42"] = event();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) =>
|
||||||
|
String(input).startsWith("/api/diagnostics/")
|
||||||
|
? new Response(JSON.stringify({ error: "store unavailable" }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
: new Response(JSON.stringify(responses[String(input)] ?? {}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderDetail(42, { retry: false });
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toContain("The server is starting or degraded.");
|
||||||
|
expect(screen.queryByText("Loading event…")).toBeNull();
|
||||||
|
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import { formatDuration, formatTime } from "@/lib/format";
|
||||||
|
import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries";
|
||||||
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import SeverityBadge from "./SeverityBadge";
|
||||||
|
import { componentLabel, copyFor } from "./eventCopy";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
back: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
textDecorationLine: "none",
|
||||||
|
},
|
||||||
|
headingRow: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
purgeAction: {
|
||||||
|
marginInlineStart: "auto",
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
wordBreak: "break-all",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
maxWidth: "48rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
facts: {
|
||||||
|
display: "grid",
|
||||||
|
gap: "0.5rem 1rem",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: "auto",
|
||||||
|
"@media (min-width: 640px)": "max-content 1fr",
|
||||||
|
},
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
term: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
sectionHeading: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
prose: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
maxWidth: "48rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.5rem",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
maxWidth: "48rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: "0.75rem",
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function DiagnosticDetailPage() {
|
||||||
|
const { id } = useParams({ from: "/shell/diagnostics/$id" });
|
||||||
|
const eventId = Number(id);
|
||||||
|
const { data, error, isPending, refetch } = useQuery(diagnosticQuery(eventId));
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const purge = useMutation(diagnosticPurgeMutation(queryClient));
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
|
function confirmPurge() {
|
||||||
|
setConfirming(false);
|
||||||
|
// The row this page is about is gone, so staying here would show the
|
||||||
|
// 404 the purge itself caused.
|
||||||
|
purge.mutate(eventId, { onSuccess: () => void navigate({ to: "/diagnostics" }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
|
||||||
|
Loading event…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data === undefined) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
||||||
|
← All diagnostics
|
||||||
|
</Link>
|
||||||
|
<InlineError error={error} onRetry={() => void refetch()} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = copyFor(data.code);
|
||||||
|
const resolvedAt = data.resolved_at;
|
||||||
|
const span = (resolvedAt ?? Math.floor(Date.now() / 1000)) - data.first_seen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
||||||
|
← All diagnostics
|
||||||
|
</Link>
|
||||||
|
<div {...stylex.props(styles.headingRow)}>
|
||||||
|
<h1 {...stylex.props(styles.heading)}>{copy.title}</h1>
|
||||||
|
<SeverityBadge severity={data.severity} />
|
||||||
|
{/* Only history can be purged: an open episode is the current state of the box. */}
|
||||||
|
{resolvedAt !== null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
disabled={purge.isPending}
|
||||||
|
{...stylex.props(styles.purgeAction, shared.dangerLinkButton, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Purge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p {...stylex.props(styles.subject)}>{data.subject}</p>
|
||||||
|
<InlineError error={purge.error} />
|
||||||
|
|
||||||
|
<div {...stylex.props(styles.panel)}>
|
||||||
|
<dl {...stylex.props(styles.facts)}>
|
||||||
|
<dt {...stylex.props(styles.term)}>State</dt>
|
||||||
|
<dd {...stylex.props(styles.value)}>
|
||||||
|
{resolvedAt === null
|
||||||
|
? `Active for ${formatDuration(span)}`
|
||||||
|
: `Resolved after ${formatDuration(span)}`}
|
||||||
|
</dd>
|
||||||
|
<dt {...stylex.props(styles.term)}>First seen</dt>
|
||||||
|
<dd {...stylex.props(styles.value)}>{formatTime(data.first_seen)}</dd>
|
||||||
|
<dt {...stylex.props(styles.term)}>Last seen</dt>
|
||||||
|
<dd {...stylex.props(styles.value)}>{formatTime(data.last_seen)}</dd>
|
||||||
|
<dt {...stylex.props(styles.term)}>Occurrences</dt>
|
||||||
|
<dd {...stylex.props(styles.value, shared.tabularNums)}>{data.occurrences}</dd>
|
||||||
|
<dt {...stylex.props(styles.term)}>Resolved</dt>
|
||||||
|
<dd {...stylex.props(styles.value)}>
|
||||||
|
{data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)}
|
||||||
|
</dd>
|
||||||
|
<dt {...stylex.props(styles.term)}>Component</dt>
|
||||||
|
<dd {...stylex.props(styles.value)}>{componentLabel(data.component)}</dd>
|
||||||
|
<dt {...stylex.props(styles.term)}>Code</dt>
|
||||||
|
<dd {...stylex.props(styles.value, shared.mono)}>{data.code}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 {...stylex.props(styles.sectionHeading)}>Impact</h2>
|
||||||
|
<p {...stylex.props(styles.prose)}>{copy.impact}</p>
|
||||||
|
|
||||||
|
<h2 {...stylex.props(styles.sectionHeading)}>What to do</h2>
|
||||||
|
<p {...stylex.props(styles.prose)}>{copy.remediation}</p>
|
||||||
|
|
||||||
|
<h2 {...stylex.props(styles.sectionHeading)}>Last error</h2>
|
||||||
|
{data.detail === "" ? (
|
||||||
|
<p {...stylex.props(styles.prose)}>The server recorded no error text for this event.</p>
|
||||||
|
) : (
|
||||||
|
<pre {...stylex.props(styles.detail, shared.mono)}>{data.detail}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{copy.link !== undefined && (
|
||||||
|
<p {...stylex.props(styles.links)}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirming}
|
||||||
|
title="Purge event"
|
||||||
|
message="Purge this resolved event? Its history is gone for good."
|
||||||
|
confirmLabel="Purge"
|
||||||
|
onConfirm={confirmPurge}
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
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 { DiagnosticEvent, DiagnosticsPage } from "@/lib/types";
|
||||||
|
|
||||||
|
// Ages are rendered against the wall clock, so the fixtures are anchored to it
|
||||||
|
// rather than to a frozen instant: faking time here would fight the query
|
||||||
|
// client's own timers for no gain.
|
||||||
|
const NOW_S = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
function event(id: number, overrides: Partial<DiagnosticEvent> = {}): DiagnosticEvent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
code: "blocklist.refresh",
|
||||||
|
component: "blocklist",
|
||||||
|
subject: "StevenBlack",
|
||||||
|
severity: "warning",
|
||||||
|
first_seen: NOW_S - 3600,
|
||||||
|
last_seen: NOW_S - 300,
|
||||||
|
occurrences: 3,
|
||||||
|
resolved_at: null,
|
||||||
|
detail: "download failed: ConnectionTimedOut",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(events: DiagnosticEvent[], nextBefore: number | null = null): DiagnosticsPage {
|
||||||
|
return { events, next_before: nextBefore, active: { warnings: 1, errors: 1 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE = page([
|
||||||
|
event(42),
|
||||||
|
event(41, {
|
||||||
|
code: "upstream.exchange",
|
||||||
|
component: "upstream",
|
||||||
|
subject: "tls://dns.example:853",
|
||||||
|
severity: "error",
|
||||||
|
occurrences: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RESOLVED = page([
|
||||||
|
event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** A stubbed response that carries a non-200 status instead of a payload. */
|
||||||
|
class Failure {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly body: unknown,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(status: number, message: string): Failure {
|
||||||
|
return new Failure(status, { error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */
|
||||||
|
const NO_CONTENT = Symbol("204");
|
||||||
|
|
||||||
|
let responses: Record<string, unknown>;
|
||||||
|
let requested: string[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requested = [];
|
||||||
|
responses = {
|
||||||
|
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
// Reads stay keyed by url alone, so the assertions below read as the
|
||||||
|
// request line they are; writes carry their method.
|
||||||
|
const key = method === "GET" ? url : `${method} ${url}`;
|
||||||
|
requested.push(key);
|
||||||
|
const payload = responses[key];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
if (payload === NO_CONTENT) return new Response(null, { status: 204 });
|
||||||
|
if (payload instanceof Failure) {
|
||||||
|
return new Response(JSON.stringify(payload.body), {
|
||||||
|
status: payload.status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `retry` is off in the failure tests: the shared client backs 5xx off for
|
||||||
|
* seconds, which the render assertions would sit through for nothing.
|
||||||
|
*/
|
||||||
|
function renderRoute(path = "/diagnostics", { retry = true } = {}) {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
if (!retry) {
|
||||||
|
const defaults = queryClient.getDefaultOptions();
|
||||||
|
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
|
||||||
|
}
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A RAC Select names its trigger with the current value and then the label. */
|
||||||
|
function trigger(label: string): HTMLElement {
|
||||||
|
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pick(label: string, option: string) {
|
||||||
|
fireEvent.click(trigger(label));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: option }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
test("active episodes come first, each with its title, subject, age and count", async () => {
|
||||||
|
renderRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Diagnostics" });
|
||||||
|
|
||||||
|
const active = screen.getByText("Blocklist source failed to update").closest("li")!;
|
||||||
|
expect(within(active).getByText("Warning")).toBeTruthy();
|
||||||
|
expect(within(active).getByText("StevenBlack")).toBeTruthy();
|
||||||
|
expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy();
|
||||||
|
|
||||||
|
const failing = screen.getByText("Upstream failing").closest("li")!;
|
||||||
|
expect(within(failing).getByText("Error")).toBeTruthy();
|
||||||
|
expect(within(failing).getByText(/1 occurrence(?!s)/)).toBeTruthy();
|
||||||
|
|
||||||
|
// The resolved history is a separate section, below the active list.
|
||||||
|
const table = within(screen.getByRole("table"));
|
||||||
|
expect(table.getByText("Disk space low")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Showing 1 resolved entry — end of history/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing open reads as good news, not as a broken page", async () => {
|
||||||
|
responses["/api/diagnostics?state=active"] = page([]);
|
||||||
|
renderRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Diagnostics" });
|
||||||
|
|
||||||
|
const healthy = await screen.findByText("No active operational issues.");
|
||||||
|
expect(healthy.getAttribute("role")).toBe("status");
|
||||||
|
// Quiet: no alert anywhere on the page, and no empty table standing in.
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a filter lands in the url and refetches both sections through it", async () => {
|
||||||
|
responses["/api/diagnostics?severity=error&state=active"] = page([
|
||||||
|
event(41, { code: "upstream.exchange", component: "upstream", severity: "error" }),
|
||||||
|
]);
|
||||||
|
responses["/api/diagnostics?severity=error&state=resolved"] = page([]);
|
||||||
|
const router = renderRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Diagnostics" });
|
||||||
|
|
||||||
|
await pick("Severity", "Errors");
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.search).toEqual({ severity: "error" }));
|
||||||
|
await waitFor(() => expect(screen.queryByText("Blocklist source failed to update")).toBeNull());
|
||||||
|
expect(requested).toContain("/api/diagnostics?severity=error&state=active");
|
||||||
|
expect(requested).toContain("/api/diagnostics?severity=error&state=resolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the state filter hides the section it excludes", async () => {
|
||||||
|
const router = renderRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Diagnostics" });
|
||||||
|
|
||||||
|
await pick("Show", "Active only");
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.search).toEqual({ state: "active" }));
|
||||||
|
expect(screen.queryByRole("heading", { name: "Resolved" })).toBeNull();
|
||||||
|
expect(screen.getByRole("heading", { name: "Active" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a url written by hand starts on the filters it names", async () => {
|
||||||
|
responses["/api/diagnostics?component=disk&state=resolved"] = RESOLVED;
|
||||||
|
renderRoute("/diagnostics?state=resolved&component=disk");
|
||||||
|
await screen.findByRole("heading", { name: "Diagnostics" });
|
||||||
|
|
||||||
|
await screen.findByText("Disk space low");
|
||||||
|
expect(screen.queryByRole("heading", { name: "Active" })).toBeNull();
|
||||||
|
expect(requested).toContain("/api/diagnostics?component=disk&state=resolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("load more appends the next page of resolved history", async () => {
|
||||||
|
responses["/api/diagnostics?state=resolved"] = page(
|
||||||
|
[event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 })],
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
responses["/api/diagnostics?state=resolved&before=30"] = page([
|
||||||
|
event(12, {
|
||||||
|
code: "certificate.reload",
|
||||||
|
component: "certificate",
|
||||||
|
subject: "doh",
|
||||||
|
resolved_at: NOW_S - 90_000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
renderRoute();
|
||||||
|
await screen.findByText("Disk space low");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
|
||||||
|
await screen.findByText("TLS certificate reload failed");
|
||||||
|
expect(screen.getByText(/Showing 2 resolved entries — end of history/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unavailable store reports the failure instead of loading forever", async () => {
|
||||||
|
responses["/api/diagnostics?state=active"] = fail(503, "store unavailable");
|
||||||
|
renderRoute("/diagnostics", { retry: false });
|
||||||
|
await screen.findByRole("heading", { name: "Diagnostics" });
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toContain("The server is starting or degraded.");
|
||||||
|
expect(screen.queryByText("Loading diagnostics…")).toBeNull();
|
||||||
|
// The resolved section answered, so it still renders its own history.
|
||||||
|
expect(screen.getByText("Disk space low")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed history query reports the failure and retries on demand", async () => {
|
||||||
|
responses["/api/diagnostics?state=resolved"] = fail(500, "diagnostics store read failed");
|
||||||
|
renderRoute("/diagnostics", { retry: false });
|
||||||
|
await screen.findByRole("heading", { name: "Diagnostics" });
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toContain("diagnostics store read failed");
|
||||||
|
expect(screen.queryByText("Loading history…")).toBeNull();
|
||||||
|
|
||||||
|
responses["/api/diagnostics?state=resolved"] = RESOLVED;
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
|
||||||
|
await screen.findByText("Disk space low");
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only the resolved history offers a purge", async () => {
|
||||||
|
renderRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Diagnostics" });
|
||||||
|
|
||||||
|
// An episode still failing is the state of the box, not history: no purge
|
||||||
|
// affordance anywhere on its card.
|
||||||
|
const active = (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")!;
|
||||||
|
expect(within(row).getByRole("button", { name: "Purge" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Purge all resolved" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no resolved history there is nothing to purge in bulk", async () => {
|
||||||
|
responses["/api/diagnostics?state=resolved"] = page([]);
|
||||||
|
renderRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Diagnostics" });
|
||||||
|
|
||||||
|
await screen.findByText("Nothing has failed and recovered in the retained window.");
|
||||||
|
expect(screen.queryByRole("button", { name: "Purge all resolved" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("purging one row asks first, then sends the DELETE and refetches the lists", async () => {
|
||||||
|
responses["DELETE /api/diagnostics/30"] = NO_CONTENT;
|
||||||
|
renderRoute();
|
||||||
|
await screen.findByText("Disk space low");
|
||||||
|
|
||||||
|
fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good.");
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(requested).not.toContain("DELETE /api/diagnostics/30");
|
||||||
|
|
||||||
|
fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
|
||||||
|
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/30"));
|
||||||
|
// The invalidation covers both sections: the page the row left and the
|
||||||
|
// active list, whose `active` counts come from the same table.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(requested.filter((url) => url === "/api/diagnostics?state=resolved").length).toBeGreaterThan(1),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(requested.filter((url) => url === "/api/diagnostics?state=active").length).toBeGreaterThan(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("purging the whole history asks first and sends one DELETE", async () => {
|
||||||
|
responses["DELETE /api/diagnostics"] = { purged: 1 };
|
||||||
|
renderRoute();
|
||||||
|
await screen.findByText("Disk space low");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Purge all resolved" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain("Purge all resolved events? Active events are kept.");
|
||||||
|
|
||||||
|
// What the server will answer once the purge has landed; the refetch the
|
||||||
|
// mutation triggers is what has to pick it up.
|
||||||
|
responses["/api/diagnostics?state=resolved"] = page([]);
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Purge all" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics"));
|
||||||
|
await waitFor(() => expect(screen.queryByText("Disk space low")).toBeNull());
|
||||||
|
expect(screen.getByText("Blocklist source failed to update")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a refused purge reports the server's reason and keeps the row", async () => {
|
||||||
|
responses["DELETE /api/diagnostics/30"] = fail(409, "the event is still active; it can be purged once it resolves");
|
||||||
|
renderRoute("/diagnostics", { retry: false });
|
||||||
|
await screen.findByText("Disk space low");
|
||||||
|
|
||||||
|
fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
|
||||||
|
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toContain("the event is still active");
|
||||||
|
expect(screen.getByText("Disk space low")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an episode links to its own detail page", async () => {
|
||||||
|
responses["/api/diagnostics/42"] = event(42);
|
||||||
|
renderRoute();
|
||||||
|
const link = await screen.findByRole("link", { name: "Blocklist source failed to update" });
|
||||||
|
expect(link.getAttribute("href")).toBe("/diagnostics/42");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an absolute window reaches both requests and is stated on the page", async () => {
|
||||||
|
const bounded = "since=1699999700&until=1700000300";
|
||||||
|
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
|
||||||
|
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
|
||||||
|
renderRoute(`/diagnostics?${bounded}`);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Active" });
|
||||||
|
expect(requested).toContain(`/api/diagnostics?${bounded}&state=active`);
|
||||||
|
expect(requested).toContain(`/api/diagnostics?${bounded}&state=resolved`);
|
||||||
|
// An empty section inside a five-minute window means something different
|
||||||
|
// from an empty section over the whole history, so the page has to say so.
|
||||||
|
expect(screen.getByText(/Showing events that overlap/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing the range drops both bounds from the url", async () => {
|
||||||
|
const bounded = "since=1699999700&until=1700000300";
|
||||||
|
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
|
||||||
|
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
|
||||||
|
const router = renderRoute(`/diagnostics?${bounded}`);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Clear the time range" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.state.location.search).not.toContain("since");
|
||||||
|
});
|
||||||
|
expect(router.state.location.search).not.toContain("until");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bound that is not a whole second is dropped, leaving the page unbounded", async () => {
|
||||||
|
renderRoute("/diagnostics?since=1.5&until=Infinity");
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Active" });
|
||||||
|
expect(requested).toContain("/api/diagnostics?state=active");
|
||||||
|
expect(screen.queryByText(/Showing events that overlap/)).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
useInfiniteQuery,
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
type InfiniteData,
|
||||||
|
type UseInfiniteQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import * as api from "@/lib/api";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import { formatDuration, formatTime } from "@/lib/format";
|
||||||
|
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
|
||||||
|
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
|
||||||
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import Select from "@/ui/Select";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import HealthStrip from "./HealthStrip";
|
||||||
|
import SeverityBadge from "./SeverityBadge";
|
||||||
|
import { diagnosticsFilterOf } from "./filter";
|
||||||
|
import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy";
|
||||||
|
|
||||||
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
const STATE_OPTIONS = [
|
||||||
|
{ value: "all", label: "Active and resolved" },
|
||||||
|
{ value: "active", label: "Active only" },
|
||||||
|
{ value: "resolved", label: "Resolved only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEVERITY_OPTIONS = [
|
||||||
|
{ value: "any", label: "Any severity" },
|
||||||
|
{ value: "warning", label: "Warnings" },
|
||||||
|
{ value: "error", label: "Errors" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMPONENT_OPTIONS = [
|
||||||
|
{ value: "any", label: "All components" },
|
||||||
|
...DIAGNOSTIC_COMPONENTS.map((component) => ({ value: component, label: componentLabel(component) })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
maxWidth: "48rem",
|
||||||
|
},
|
||||||
|
filterGrid: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
display: "grid",
|
||||||
|
gap: "0.75rem",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: "repeat(1, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
|
||||||
|
},
|
||||||
|
maxWidth: "48rem",
|
||||||
|
},
|
||||||
|
sectionHeading: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
sectionHeadingRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Nothing open is the normal state of a working install, so it gets one
|
||||||
|
* quiet muted line — no border, no icon, no alert role. A panel here would
|
||||||
|
* read as a broken page rather than as good news.
|
||||||
|
*/
|
||||||
|
healthy: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
cardList: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.5rem",
|
||||||
|
listStyleType: "none",
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.625rem",
|
||||||
|
},
|
||||||
|
cardTop: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontWeight: 500,
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
textDecorationLine: "none",
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
wordBreak: "break-all",
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
rangeNotice: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceHover,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
tableWrap: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
head: {
|
||||||
|
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||||
|
borderTopStyle: "solid",
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
nowrap: {
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
rowLink: {
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
textDecorationLine: "none",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
moreError: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Section = UseInfiniteQueryResult<InfiniteData<Page, unknown>, Error>;
|
||||||
|
|
||||||
|
/** The two enum filters, narrowed from the picker's string rather than cast. */
|
||||||
|
function asState(value: string): DiagnosticState | undefined {
|
||||||
|
return value === "active" || value === "resolved" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asSeverity(value: string): DiagnosticSeverity | undefined {
|
||||||
|
return value === "warning" || value === "error" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function occurrenceText(count: number): string {
|
||||||
|
return `${count} ${count === 1 ? "occurrence" : "occurrences"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsOf(section: Section): DiagnosticEvent[] {
|
||||||
|
return (section.data?.pages ?? []).flatMap((page) => page.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cursor comes from the newest page on screen, not from `hasNextPage`:
|
||||||
|
* while placeholder data stands in for a filter change the query state is
|
||||||
|
* empty, and the button would flash away and back.
|
||||||
|
*/
|
||||||
|
function hasMore(section: Section): boolean {
|
||||||
|
const pages = section.data?.pages ?? [];
|
||||||
|
const last = pages[pages.length - 1];
|
||||||
|
return last !== undefined && last.next_before !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoreButton({ section }: { section: Section }) {
|
||||||
|
const more = hasMore(section);
|
||||||
|
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
||||||
|
const isUnauthorized = section.error instanceof api.ApiError && section.error.status === 401;
|
||||||
|
const failed = section.isFetchNextPageError && !isUnauthorized ? errorMessage(section.error) : null;
|
||||||
|
if (!more && failed === null) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div {...stylex.props(styles.footer)}>
|
||||||
|
{more && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (section.isFetchingNextPage || section.isPlaceholderData) return;
|
||||||
|
void section.fetchNextPage();
|
||||||
|
}}
|
||||||
|
disabled={section.isFetchingNextPage || section.isPlaceholderData}
|
||||||
|
{...stylex.props(shared.button, shared.focusRing)}
|
||||||
|
>
|
||||||
|
{section.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{failed !== null && (
|
||||||
|
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||||
|
Failed to load more: {failed}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window the page is bounded to, whenever it is bounded.
|
||||||
|
*
|
||||||
|
* A link from a query detail arrives with an absolute five-minute window, and
|
||||||
|
* an empty Active section inside it means something very different from an
|
||||||
|
* empty Active section over the whole history. The page has to say which it is
|
||||||
|
* showing, and offer the way out of it.
|
||||||
|
*/
|
||||||
|
function RangeNotice({ since, until }: { since?: number; until?: number }) {
|
||||||
|
const navigate = useNavigate({ from: "/diagnostics" });
|
||||||
|
if (since === undefined && until === undefined) return null;
|
||||||
|
const from = since === undefined ? "the start of the history" : formatTime(since);
|
||||||
|
const to = until === undefined ? "now" : formatTime(until);
|
||||||
|
return (
|
||||||
|
<p role="status" {...stylex.props(styles.rangeNotice)}>
|
||||||
|
Showing events that overlap {from} to {to}.{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate({ search: (prev) => ({ ...prev, since: undefined, until: undefined }) })}
|
||||||
|
{...stylex.props(shared.linkButton, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Clear the time range
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveCard({ event, now }: { event: DiagnosticEvent; now: number }) {
|
||||||
|
const copy = copyFor(event.code);
|
||||||
|
return (
|
||||||
|
<li {...stylex.props(styles.card)}>
|
||||||
|
<div {...stylex.props(styles.cardTop)}>
|
||||||
|
<SeverityBadge severity={event.severity} />
|
||||||
|
<Link
|
||||||
|
to="/diagnostics/$id"
|
||||||
|
params={{ id: String(event.id) }}
|
||||||
|
{...stylex.props(styles.cardTitle, shared.focusRing)}
|
||||||
|
>
|
||||||
|
{copy.title}
|
||||||
|
</Link>
|
||||||
|
<span {...stylex.props(styles.subject)}>{event.subject}</span>
|
||||||
|
</div>
|
||||||
|
<p {...stylex.props(styles.meta)}>
|
||||||
|
Active for {formatDuration(now - event.first_seen)} · {occurrenceText(event.occurrences)} · last failure{" "}
|
||||||
|
{formatTime(event.last_seen)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge: () => void; busy: boolean }) {
|
||||||
|
const copy = copyFor(event.code);
|
||||||
|
return (
|
||||||
|
<tr {...stylex.props(styles.row)}>
|
||||||
|
<td {...stylex.props(styles.cell)}>
|
||||||
|
<SeverityBadge severity={event.severity} />
|
||||||
|
</td>
|
||||||
|
<td {...stylex.props(styles.cell)}>
|
||||||
|
<Link
|
||||||
|
to="/diagnostics/$id"
|
||||||
|
params={{ id: String(event.id) }}
|
||||||
|
{...stylex.props(styles.rowLink, shared.focusRing)}
|
||||||
|
>
|
||||||
|
{copy.title}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td {...stylex.props(styles.cell)}>{event.subject}</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap)}>{formatTime(event.first_seen)}</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||||
|
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
||||||
|
</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPurge}
|
||||||
|
disabled={busy}
|
||||||
|
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Purge
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiagnosticsPage() {
|
||||||
|
const search = useSearch({ from: "/shell/diagnostics" });
|
||||||
|
const navigate = useNavigate({ from: "/diagnostics" });
|
||||||
|
const state = search.state ?? "all";
|
||||||
|
|
||||||
|
const base = diagnosticsFilterOf(search);
|
||||||
|
|
||||||
|
const active = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "active" }, state !== "resolved"));
|
||||||
|
const history = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "resolved" }, state !== "active"));
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const purgeOne = useMutation(diagnosticPurgeMutation(queryClient));
|
||||||
|
const purgeAll = useMutation(diagnosticsPurgeResolvedMutation(queryClient));
|
||||||
|
// `null` is "no dialog"; the id is which row it is about, and `"all"` the
|
||||||
|
// whole history. One piece of state, so the two dialogs cannot both be open.
|
||||||
|
const [pendingPurge, setPendingPurge] = useState<number | "all" | null>(null);
|
||||||
|
|
||||||
|
const activeRows = rowsOf(active);
|
||||||
|
const historyRows = rowsOf(history);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const purging = purgeOne.isPending || purgeAll.isPending;
|
||||||
|
|
||||||
|
function setSearch(patch: Partial<typeof search>) {
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, ...patch }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmPurge() {
|
||||||
|
if (pendingPurge === null) return;
|
||||||
|
if (pendingPurge === "all") {
|
||||||
|
purgeAll.mutate();
|
||||||
|
} else {
|
||||||
|
purgeOne.mutate(pendingPurge);
|
||||||
|
}
|
||||||
|
setPendingPurge(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 {...stylex.props(styles.heading)}>Diagnostics</h1>
|
||||||
|
<p {...stylex.props(styles.intro)}>
|
||||||
|
Operational failures, one entry per subject that failed. An entry opens on the first failure, counts
|
||||||
|
repeats, and closes when the subject recovers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<HealthStrip />
|
||||||
|
|
||||||
|
<RangeNotice since={search.since} until={search.until} />
|
||||||
|
|
||||||
|
<div {...stylex.props(styles.filterGrid)}>
|
||||||
|
<Select
|
||||||
|
variant="compactField"
|
||||||
|
label="Show"
|
||||||
|
value={state}
|
||||||
|
onChange={(value) => setSearch({ state: asState(value) })}
|
||||||
|
options={STATE_OPTIONS}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
variant="compactField"
|
||||||
|
label="Severity"
|
||||||
|
value={search.severity ?? "any"}
|
||||||
|
onChange={(value) => setSearch({ severity: asSeverity(value) })}
|
||||||
|
options={SEVERITY_OPTIONS}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
variant="compactField"
|
||||||
|
label="Component"
|
||||||
|
value={search.component ?? "any"}
|
||||||
|
onChange={(value) => setSearch({ component: value === "any" ? undefined : value })}
|
||||||
|
options={COMPONENT_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state !== "resolved" && (
|
||||||
|
<>
|
||||||
|
<h2 {...stylex.props(styles.sectionHeading)}>Active</h2>
|
||||||
|
{active.status === "error" ? (
|
||||||
|
<InlineError error={active.error} onRetry={() => void active.refetch()} />
|
||||||
|
) : active.data === undefined ? (
|
||||||
|
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||||
|
Loading diagnostics…
|
||||||
|
</p>
|
||||||
|
) : activeRows.length === 0 ? (
|
||||||
|
<p {...stylex.props(styles.healthy)} role="status">
|
||||||
|
No active operational issues.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul {...stylex.props(styles.cardList)}>
|
||||||
|
{activeRows.map((event) => (
|
||||||
|
<ActiveCard key={event.id} event={event} now={now} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<MoreButton section={active} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state !== "active" && (
|
||||||
|
<>
|
||||||
|
<div {...stylex.props(styles.sectionHeading, styles.sectionHeadingRow)}>
|
||||||
|
<h2>Resolved</h2>
|
||||||
|
{historyRows.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingPurge("all")}
|
||||||
|
disabled={purging}
|
||||||
|
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Purge all resolved
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{history.status === "error" ? (
|
||||||
|
<InlineError error={history.error} onRetry={() => void history.refetch()} />
|
||||||
|
) : history.data === undefined ? (
|
||||||
|
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||||
|
Loading history…
|
||||||
|
</p>
|
||||||
|
) : historyRows.length === 0 ? (
|
||||||
|
<p {...stylex.props(styles.empty)}>Nothing has failed and recovered in the retained window.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
|
<table {...stylex.props(styles.table)}>
|
||||||
|
<thead {...stylex.props(styles.head)}>
|
||||||
|
<tr>
|
||||||
|
<th {...stylex.props(styles.th)}>Severity</th>
|
||||||
|
<th {...stylex.props(styles.th)}>Event</th>
|
||||||
|
<th {...stylex.props(styles.th)}>Subject</th>
|
||||||
|
<th {...stylex.props(styles.th)}>Started</th>
|
||||||
|
<th {...stylex.props(styles.th)}>Resolved</th>
|
||||||
|
<th {...stylex.props(styles.th)}>Occurrences</th>
|
||||||
|
<th {...stylex.props(styles.th)}>
|
||||||
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{historyRows.map((event) => (
|
||||||
|
<HistoryRow
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
busy={purging}
|
||||||
|
onPurge={() => setPendingPurge(event.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p {...stylex.props(styles.footer, styles.note)}>
|
||||||
|
Showing {historyRows.length} resolved {historyRows.length === 1 ? "entry" : "entries"}
|
||||||
|
{hasMore(history) ? "" : " — end of history"}
|
||||||
|
</p>
|
||||||
|
<MoreButton section={history} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<InlineError error={purgeOne.error ?? purgeAll.error} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingPurge !== null}
|
||||||
|
title={pendingPurge === "all" ? "Purge resolved history" : "Purge event"}
|
||||||
|
message={
|
||||||
|
pendingPurge === "all"
|
||||||
|
? "Purge all resolved events? Active events are kept."
|
||||||
|
: "Purge this resolved event? Its history is gone for good."
|
||||||
|
}
|
||||||
|
confirmLabel={pendingPurge === "all" ? "Purge all" : "Purge"}
|
||||||
|
onConfirm={confirmPurge}
|
||||||
|
onCancel={() => setPendingPurge(null)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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()} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* The severity chip both diagnostics views carry. The word is the affordance —
|
||||||
|
* colour alone would leave the severity unreadable to a screen reader and to
|
||||||
|
* anyone who does not separate the amber from the red.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import type { DiagnosticSeverity } from "@/lib/types";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
badge: {
|
||||||
|
display: "inline-block",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
paddingInline: "0.375rem",
|
||||||
|
paddingBlock: "0.125rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
borderColor: colors.warnBorder,
|
||||||
|
backgroundColor: colors.warnSurface,
|
||||||
|
color: colors.warnText,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
borderColor: colors.dangerBorder,
|
||||||
|
backgroundColor: colors.dangerSurface,
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function SeverityBadge({ severity }: { severity: DiagnosticSeverity }) {
|
||||||
|
return (
|
||||||
|
<span {...stylex.props(styles.badge, severity === "error" ? styles.error : styles.warning)}>
|
||||||
|
{severity === "error" ? "Error" : "Warning"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { DIAGNOSTIC_CODES } from "@/lib/types";
|
||||||
|
import { DIAGNOSTIC_COMPONENTS, EVENT_COPY, componentLabel, copyFor } from "./eventCopy";
|
||||||
|
|
||||||
|
test("the enum holds the fifteen codes the store defines", () => {
|
||||||
|
expect(DIAGNOSTIC_CODES).toHaveLength(15);
|
||||||
|
expect(new Set(DIAGNOSTIC_CODES).size).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every code has copy, and no copy belongs to a code that does not exist", () => {
|
||||||
|
for (const code of DIAGNOSTIC_CODES) {
|
||||||
|
const copy = EVENT_COPY[code];
|
||||||
|
expect(copy, code).toBeDefined();
|
||||||
|
expect(copy.title.length, code).toBeGreaterThan(0);
|
||||||
|
expect(copy.impact.length, code).toBeGreaterThan(0);
|
||||||
|
expect(copy.remediation.length, code).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
expect(Object.keys(EVENT_COPY).sort()).toEqual([...DIAGNOSTIC_CODES].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("titles are distinct, so two open episodes never read as the same event", () => {
|
||||||
|
const titles = DIAGNOSTIC_CODES.map((code) => EVENT_COPY[code].title);
|
||||||
|
expect(new Set(titles).size).toBe(titles.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a code this build has never heard of falls back to the code itself", () => {
|
||||||
|
// The server is the authority on the enum; a newer one can send a sixteenth.
|
||||||
|
const copy = copyFor("nonsense.code" as (typeof DIAGNOSTIC_CODES)[number]);
|
||||||
|
expect(copy.title).toBe("nonsense.code");
|
||||||
|
expect(copy.remediation.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the component options are the code prefixes, deduplicated and in enum order", () => {
|
||||||
|
expect(DIAGNOSTIC_COMPONENTS).toEqual([
|
||||||
|
"disk",
|
||||||
|
"blocklist",
|
||||||
|
"certificate",
|
||||||
|
"query_log",
|
||||||
|
"upstream_history",
|
||||||
|
"upstream",
|
||||||
|
"client_names",
|
||||||
|
"clients",
|
||||||
|
"listener",
|
||||||
|
"configuration",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("component labels read as prose without inventing a name", () => {
|
||||||
|
expect(componentLabel("query_log")).toBe("Query log");
|
||||||
|
expect(componentLabel("disk")).toBe("Disk");
|
||||||
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* What each event code means to the operator, in three fixed fields: what the
|
||||||
|
* episode is (`title`), what it costs while it stays open (`impact`), and what
|
||||||
|
* to do about it (`remediation`). The server sends a code and an error string;
|
||||||
|
* every word of explanation the page shows comes from here.
|
||||||
|
*
|
||||||
|
* The record is exhaustive over `DiagnosticCode` by type, and a test walks
|
||||||
|
* `DIAGNOSTIC_CODES` to prove it at runtime too. A sixteenth code added to the
|
||||||
|
* enum fails `tsc` here before it can reach the page as a bare dotted string.
|
||||||
|
*
|
||||||
|
* `link` points at the configuration surface that governs the failure. 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 =
|
||||||
|
"/configuration/system" | "/configuration/protection" | "/configuration/resolution" | "/clients";
|
||||||
|
|
||||||
|
export interface EventCopy {
|
||||||
|
title: string;
|
||||||
|
impact: string;
|
||||||
|
remediation: string;
|
||||||
|
link?: { to: CopyLinkPath; label: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The copy for a code, with a floor under it. `tsc` proves the record covers
|
||||||
|
* the union, but a server one release ahead can send a code this build has
|
||||||
|
* never heard of; showing the raw code beats rendering "undefined".
|
||||||
|
*/
|
||||||
|
export function copyFor(code: DiagnosticCode): EventCopy {
|
||||||
|
return (
|
||||||
|
EVENT_COPY[code] ?? {
|
||||||
|
title: code,
|
||||||
|
impact: "This build has no description for this event code.",
|
||||||
|
remediation: "The error detail below is the whole of what the server reported.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS = { to: "/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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The component filter's options, derived from the codes rather than listed
|
||||||
|
* again: the server matches `component` against the part of `code` before the
|
||||||
|
* dot, so any list written by hand here could drift from the enum.
|
||||||
|
*/
|
||||||
|
export const DIAGNOSTIC_COMPONENTS: readonly string[] = [
|
||||||
|
...new Set(DIAGNOSTIC_CODES.map((code) => code.slice(0, code.indexOf(".")))),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** `query_log` → "Query log". Display only; the filter sends the raw component. */
|
||||||
|
export function componentLabel(component: string): string {
|
||||||
|
const spaced = component.replaceAll("_", " ");
|
||||||
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EVENT_COPY: Record<DiagnosticCode, EventCopy> = {
|
||||||
|
"disk.space": {
|
||||||
|
title: "Disk space low",
|
||||||
|
impact: "Below the critical threshold nxdns stops blocklist updates and query log flushes to protect the disk.",
|
||||||
|
remediation: "Free space on the data volume, or lower the retention window so the query log holds fewer days.",
|
||||||
|
link: SETTINGS,
|
||||||
|
},
|
||||||
|
"disk.probe": {
|
||||||
|
title: "Disk usage probe failed",
|
||||||
|
impact: "Free space is unknown, so the low-disk guard cannot act until a probe succeeds.",
|
||||||
|
remediation: "Check that the data and log directories exist and that the service user can read them.",
|
||||||
|
link: SETTINGS,
|
||||||
|
},
|
||||||
|
"blocklist.refresh": {
|
||||||
|
title: "Blocklist source failed to update",
|
||||||
|
impact: "The source keeps serving its last good snapshot, so blocking continues but the list ages.",
|
||||||
|
remediation: "Check the source url and the machine's internet access, then update the lists again.",
|
||||||
|
link: BLOCKLISTS,
|
||||||
|
},
|
||||||
|
"blocklist.snapshot": {
|
||||||
|
title: "Filter snapshot failed to publish",
|
||||||
|
impact: "The resolver keeps the snapshot it already holds; blocklist edits do not take effect until one publishes.",
|
||||||
|
remediation: "Check free disk space and the data directory's permissions, then update the lists again.",
|
||||||
|
link: BLOCKLISTS,
|
||||||
|
},
|
||||||
|
"blocklist.storage": {
|
||||||
|
title: "Blocklist storage operation failed",
|
||||||
|
impact: "Cached list files or their database rows are out of step; a later pass can redownload what is missing.",
|
||||||
|
remediation: "Check free disk space and the data directory's permissions.",
|
||||||
|
link: BLOCKLISTS,
|
||||||
|
},
|
||||||
|
"certificate.reload": {
|
||||||
|
title: "TLS certificate reload failed",
|
||||||
|
impact: "The endpoint keeps serving the certificate it already loaded, which expires on its own schedule.",
|
||||||
|
remediation:
|
||||||
|
"Check the certificate and key paths, and that renewal writes both files the service user can read.",
|
||||||
|
link: SETTINGS,
|
||||||
|
},
|
||||||
|
"query_log.write": {
|
||||||
|
title: "Query log write failed",
|
||||||
|
impact: "Queries are resolved and answered as usual, but they are not being recorded.",
|
||||||
|
remediation: "Check free disk space and the log database's permissions, then restart nxdns.",
|
||||||
|
link: SETTINGS,
|
||||||
|
},
|
||||||
|
"query_log.maintenance": {
|
||||||
|
title: "Query log maintenance failed",
|
||||||
|
impact: "Old rows are not being trimmed, so the log database grows past its retention window.",
|
||||||
|
remediation: "Check free disk space; the next maintenance pass retries on its own.",
|
||||||
|
link: SETTINGS,
|
||||||
|
},
|
||||||
|
"query_log.recreated": {
|
||||||
|
title: "Query log recreated",
|
||||||
|
impact: "The old log database was 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: "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": {
|
||||||
|
title: "Upstream failing",
|
||||||
|
impact: "Queries fall through to the remaining upstreams; answers are slower while this one backs off.",
|
||||||
|
remediation: "Check the upstream's reachability and its TLS name. Remove it if it stays down.",
|
||||||
|
link: UPSTREAMS,
|
||||||
|
},
|
||||||
|
"client_names.storage": {
|
||||||
|
title: "Client name storage failed",
|
||||||
|
impact: "Learned reverse-DNS names are not persisted, so clients can show as bare addresses after a restart.",
|
||||||
|
remediation: "Check free disk space and the configuration database's permissions.",
|
||||||
|
link: CLIENTS,
|
||||||
|
},
|
||||||
|
"clients.storage": {
|
||||||
|
title: "Client record storage failed",
|
||||||
|
impact: "New clients may not appear in the list and stale ones may not be pruned.",
|
||||||
|
remediation: "Check free disk space and the configuration database's permissions.",
|
||||||
|
link: CLIENTS,
|
||||||
|
},
|
||||||
|
"listener.start": {
|
||||||
|
title: "Encrypted DNS listener failed to start",
|
||||||
|
impact: "That endpoint is not accepting queries. Plain DNS on port 53 is unaffected.",
|
||||||
|
remediation:
|
||||||
|
"Check the bind address, the port, and the certificate paths, then restart nxdns. The episode closes on a clean start.",
|
||||||
|
link: SETTINGS,
|
||||||
|
},
|
||||||
|
"configuration.load": {
|
||||||
|
title: "Configuration problem at startup",
|
||||||
|
impact: "The setting named below was rejected or replaced by its default for this run.",
|
||||||
|
remediation: "Correct the setting and restart nxdns. The episode closes on a clean start.",
|
||||||
|
link: SETTINGS,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* The Diagnostics search parameters and the API filter they build.
|
||||||
|
*
|
||||||
|
* The route, the page and the two infinite queries all have to agree on what
|
||||||
|
* the URL asked for — the page renders the same window the loader prefetched —
|
||||||
|
* so the projection lives in one place rather than being spelled out at each.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DiagnosticSeverity, DiagnosticState, DiagnosticsFilter } from "@/lib/types";
|
||||||
|
|
||||||
|
export interface DiagnosticsSearch {
|
||||||
|
state?: DiagnosticState;
|
||||||
|
severity?: DiagnosticSeverity;
|
||||||
|
component?: string;
|
||||||
|
/** Unix seconds, inclusive. An episode qualifies when its interval overlaps. */
|
||||||
|
since?: number;
|
||||||
|
until?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field by field, so an unset filter is an absent key rather than `undefined`. */
|
||||||
|
export function diagnosticsFilterOf(search: DiagnosticsSearch): DiagnosticsFilter {
|
||||||
|
const filter: DiagnosticsFilter = {};
|
||||||
|
if (search.severity !== undefined) filter.severity = search.severity;
|
||||||
|
if (search.component !== undefined) filter.component = search.component;
|
||||||
|
if (search.since !== undefined) filter.since = search.since;
|
||||||
|
if (search.until !== undefined) filter.until = search.until;
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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,197 +0,0 @@
|
|||||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { createQueryClient } from "@/lib/queryClient";
|
|
||||||
import type { Client, LiveQueryEvent } from "@/lib/types";
|
|
||||||
import { FakeEventSource } from "./fakeEventSource";
|
|
||||||
import LiveLogPage from "./LiveLogPage";
|
|
||||||
|
|
||||||
function client(ip: string, name: string, learnedName: string): Client {
|
|
||||||
return {
|
|
||||||
id: Number(ip.split(".").pop()),
|
|
||||||
ip,
|
|
||||||
name,
|
|
||||||
learned_name: learnedName,
|
|
||||||
group_id: 1,
|
|
||||||
group: "default",
|
|
||||||
hand_edited: name !== "",
|
|
||||||
first_seen: 1_700_000_000,
|
|
||||||
last_seen: 1_700_000_100,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const CLIENTS: Client[] = [
|
|
||||||
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
|
|
||||||
client("192.0.2.11", "", "laptop.lan"),
|
|
||||||
client("192.0.2.12", "", ""),
|
|
||||||
];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
|
||||||
if (String(input) !== "/api/clients") {
|
|
||||||
return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ clients: CLIENTS }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
|
|
||||||
const payload: LiveQueryEvent = {
|
|
||||||
ts,
|
|
||||||
domain,
|
|
||||||
client_ip: "192.0.2.10",
|
|
||||||
qtype: 1,
|
|
||||||
blocked: false,
|
|
||||||
block_reason: "",
|
|
||||||
response_time_us: 500,
|
|
||||||
cache_hit: true,
|
|
||||||
upstream: "",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
return { data: JSON.stringify(payload) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
const sources: FakeEventSource[] = [];
|
|
||||||
const createEventSource = (url: string) => {
|
|
||||||
const es = new FakeEventSource(url);
|
|
||||||
sources.push(es);
|
|
||||||
return es;
|
|
||||||
};
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={createQueryClient()}>
|
|
||||||
<LiveLogPage createEventSource={createEventSource} />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
return sources;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("streams rows, flags blocked ones, and freezes the display", () => {
|
|
||||||
const sources = renderPage();
|
|
||||||
expect(screen.getByText("Connecting…")).toBeTruthy();
|
|
||||||
|
|
||||||
act(() => sources[0]!.emit("open"));
|
|
||||||
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
|
|
||||||
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
sources[0]!.emit("query", frame(1000, "ok.example"));
|
|
||||||
sources[0]!.emit(
|
|
||||||
"query",
|
|
||||||
frame(1001, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack", qtype: 28 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("ok.example")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
|
||||||
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
|
|
||||||
expect(screen.getByText("AAAA")).toBeTruthy();
|
|
||||||
// StyleX compiles to opaque class names, so the check is structural: a blocked
|
|
||||||
// row carries every class a plain row does, plus the ones the flag adds.
|
|
||||||
const blockedRow = screen.getByText("ads.example").closest("tr");
|
|
||||||
const plainRow = screen.getByText("ok.example").closest("tr");
|
|
||||||
const blockedClasses = new Set(blockedRow?.className.split(" "));
|
|
||||||
const plainClasses = plainRow?.className.split(" ") ?? [];
|
|
||||||
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
|
|
||||||
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
|
|
||||||
|
|
||||||
const freeze = screen.getByRole("button", { name: "Freeze" });
|
|
||||||
fireEvent.click(freeze);
|
|
||||||
expect(freeze.getAttribute("aria-pressed")).toBe("true");
|
|
||||||
|
|
||||||
act(() => sources[0]!.emit("query", frame(1002, "later.example")));
|
|
||||||
expect(screen.queryByText("later.example")).toBeNull();
|
|
||||||
expect(screen.getByText(/3 in buffer/)).toBeTruthy();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
|
||||||
expect(screen.getByText("later.example")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
|
||||||
const sources = renderPage();
|
|
||||||
act(() => sources[0]!.emit("open"));
|
|
||||||
act(() => {
|
|
||||||
sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" }));
|
|
||||||
sources[0]!.emit("query", frame(1001, "learned.example", { client_ip: "192.0.2.11" }));
|
|
||||||
sources[0]!.emit("query", frame(1002, "nameless.example", { client_ip: "192.0.2.12" }));
|
|
||||||
sources[0]!.emit("query", frame(1003, "stranger.example", { client_ip: "192.0.2.99" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
|
||||||
const named = await screen.findByText("Kitchen Pi");
|
|
||||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
|
||||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
|
||||||
|
|
||||||
// The cell holds the learned name followed by the tag, so the match is on
|
|
||||||
// the containing span rather than on a bare text node.
|
|
||||||
const learned = screen.getByText(
|
|
||||||
(content, element) => element?.tagName === "SPAN" && content.startsWith("laptop.lan"),
|
|
||||||
);
|
|
||||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
|
||||||
// The affordance is text, not colour, so a screen reader announces it too.
|
|
||||||
expect(within(learned).getByText("learned")).toBeTruthy();
|
|
||||||
|
|
||||||
// A known client with neither name, and a client the loaded list has never
|
|
||||||
// seen, both fall back to the bare address with no tooltip standing in.
|
|
||||||
const nameless = screen.getByText("192.0.2.12");
|
|
||||||
expect(nameless.getAttribute("title")).toBeNull();
|
|
||||||
const stranger = screen.getByText("192.0.2.99");
|
|
||||||
expect(stranger.getAttribute("title")).toBeNull();
|
|
||||||
expect(screen.getByText("stranger.example").closest("tr")?.textContent).toContain("192.0.2.99");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rows stream in as bare IPs while the client list is still loading", async () => {
|
|
||||||
let releaseClients: () => void = () => {};
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(
|
|
||||||
(input: RequestInfo | URL) =>
|
|
||||||
new Promise<Response>((resolve) => {
|
|
||||||
if (String(input) !== "/api/clients") {
|
|
||||||
resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
releaseClients = () =>
|
|
||||||
resolve(
|
|
||||||
new Response(JSON.stringify({ clients: CLIENTS }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const sources = renderPage();
|
|
||||||
act(() => sources[0]!.emit("open"));
|
|
||||||
act(() => sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" })));
|
|
||||||
|
|
||||||
expect(screen.getByText("192.0.2.10")).toBeTruthy();
|
|
||||||
expect(screen.queryByText("Kitchen Pi")).toBeNull();
|
|
||||||
|
|
||||||
releaseClients();
|
|
||||||
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("repeated connection failures show the viewer-cap state with a retry button", () => {
|
|
||||||
const sources = renderPage();
|
|
||||||
act(() => {
|
|
||||||
sources[0]!.emit("error");
|
|
||||||
sources[0]!.emit("error");
|
|
||||||
sources[0]!.emit("error");
|
|
||||||
});
|
|
||||||
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
||||||
expect(sources).toHaveLength(2);
|
|
||||||
expect(screen.getByText("Connecting…")).toBeTruthy();
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
|
|
||||||
import { RING_CAPACITY, mergeGap, pushRow, type LiveRow } from "./ringBuffer";
|
|
||||||
|
|
||||||
function event(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): LiveQueryEvent {
|
|
||||||
return {
|
|
||||||
ts,
|
|
||||||
domain,
|
|
||||||
client_ip: "192.0.2.10",
|
|
||||||
qtype: 1,
|
|
||||||
blocked: false,
|
|
||||||
block_reason: "",
|
|
||||||
response_time_us: 500,
|
|
||||||
cache_hit: false,
|
|
||||||
upstream: "udp://9.9.9.9:53",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function liveRow(key: number, ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): LiveRow {
|
|
||||||
return { ...event(ts, domain, overrides), key };
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchedRow(id: number, ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): QueryRow {
|
|
||||||
return { id, ...event(ts, domain, overrides) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function counter(start = 100): () => number {
|
|
||||||
let n = start;
|
|
||||||
return () => ++n;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("pushRow", () => {
|
|
||||||
test("prepends newest-first", () => {
|
|
||||||
let rows: LiveRow[] = [];
|
|
||||||
rows = pushRow(rows, liveRow(1, 10, "a.example"));
|
|
||||||
rows = pushRow(rows, liveRow(2, 11, "b.example"));
|
|
||||||
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("drops the oldest beyond capacity", () => {
|
|
||||||
let rows: LiveRow[] = [];
|
|
||||||
for (let i = 0; i < 5; i++) rows = pushRow(rows, liveRow(i, i, `d${i}.example`), 3);
|
|
||||||
expect(rows).toHaveLength(3);
|
|
||||||
expect(rows.map((r) => r.key)).toEqual([4, 3, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("default capacity is 500", () => {
|
|
||||||
let rows: LiveRow[] = [];
|
|
||||||
for (let i = 0; i < RING_CAPACITY + 10; i++) rows = pushRow(rows, liveRow(i, i, "x.example"));
|
|
||||||
expect(rows).toHaveLength(RING_CAPACITY);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mergeGap", () => {
|
|
||||||
test("skips rows already in the buffer and counts only new ones", () => {
|
|
||||||
const buffer = [liveRow(2, 100, "seen.example"), liveRow(1, 99, "old.example")];
|
|
||||||
const fetched = [
|
|
||||||
fetchedRow(30, 102, "gap2.example"),
|
|
||||||
fetchedRow(29, 101, "gap1.example"),
|
|
||||||
fetchedRow(28, 100, "seen.example"),
|
|
||||||
];
|
|
||||||
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
|
||||||
expect(missed).toBe(2);
|
|
||||||
expect(rows.map((r) => r.domain)).toEqual(["gap2.example", "gap1.example", "seen.example", "old.example"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no additions returns the buffer unchanged with missed 0", () => {
|
|
||||||
const buffer = [liveRow(1, 100, "seen.example")];
|
|
||||||
const { rows, missed } = mergeGap(buffer, [fetchedRow(5, 100, "seen.example")], counter());
|
|
||||||
expect(missed).toBe(0);
|
|
||||||
expect(rows).toBe(buffer);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rows differing only in qtype are not deduplicated", () => {
|
|
||||||
const buffer = [liveRow(1, 100, "dual.example", { qtype: 1 })];
|
|
||||||
const fetched = [fetchedRow(5, 100, "dual.example", { qtype: 28 })];
|
|
||||||
const { missed } = mergeGap(buffer, fetched, counter());
|
|
||||||
expect(missed).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("assigns fresh keys from the counter and drops the id", () => {
|
|
||||||
const { rows } = mergeGap([], [fetchedRow(77, 100, "gap.example")], counter(200));
|
|
||||||
expect(rows[0]?.key).toBe(201);
|
|
||||||
expect("id" in (rows[0] ?? {})).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("result is capped at capacity, keeping the newest", () => {
|
|
||||||
const buffer = [liveRow(3, 300, "live.example")];
|
|
||||||
const fetched = [fetchedRow(2, 302, "g2.example"), fetchedRow(1, 301, "g1.example")];
|
|
||||||
const { rows, missed } = mergeGap(buffer, fetched, counter(), 2);
|
|
||||||
expect(missed).toBe(2);
|
|
||||||
expect(rows.map((r) => r.domain)).toEqual(["g2.example", "g1.example"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("merged rows stay sorted newest-first by ts", () => {
|
|
||||||
const buffer = [liveRow(4, 105, "after-reopen.example"), liveRow(3, 100, "before.example")];
|
|
||||||
const fetched = [fetchedRow(9, 103, "gap.example")];
|
|
||||||
const { rows } = mergeGap(buffer, fetched, counter());
|
|
||||||
expect(rows.map((r) => r.ts)).toEqual([105, 103, 100]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user