Compare commits
14
Commits
v0.0.6
...
648d9b4496
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
648d9b4496
|
||
|
|
17422fac21
|
||
|
|
fa323c7ed4
|
||
|
|
0fd6bbd312
|
||
|
|
7e6cb507d2
|
||
|
|
8a17e9ed21
|
||
|
|
0107df5f99
|
||
|
|
b6cea3f539
|
||
|
|
9b0b7c19f4
|
||
|
|
324704b53f
|
||
|
|
addf24f92c
|
||
|
|
037f209179
|
||
|
|
3dd8214ef2
|
||
|
|
64c0d723a6
|
@@ -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.
|
||||
@@ -6,6 +6,66 @@ Sections are written by hand. Nothing here is generated from commit messages: th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Query provenance: every logged query becomes exactly explainable — what the policy decided, what matched, where the answer came from and what the client saw. The handler records all of it as the reply goes out, `query_log` stores it, and a detail page reads one query back in the order the pipeline decided it. Read the upgrade note below first: it resets your query history.
|
||||
|
||||
### Added
|
||||
|
||||
- **Every logged query has a detail page.** A row in Activity now links to `/activity/queries/{id}`, which explains that one query in the order it was decided: the request, the group it was matched under, the policy verdict with the rule that produced it and the blocklist source that rule came from, any CNAME uncloaking or safe-search rewrite, the route the answer took — blocked, local, forward zone, upstream or cache — and what the client got back, RCODE and duration included. `GET /api/queries/{id}` serves the same object; an id that retention has already deleted is a 404. The live view carries the same provenance for the queries it streams, so a query is explainable as it happens as well as afterwards.
|
||||
- **Query Log, Live and Lookup are one Activity page.** `/activity` is the single surface for what nxdns answered: History reads the stored log, Live reads the stream, and both show the same seven columns — Time, Domain, Client, Type, Result, Route, Duration. The mode and every filter live in the URL, so an investigation is one link that shows the recipient exactly what you were looking at, and an absolute time range stays that range instead of drifting as the day goes on. A new **Result** column says what the client actually got — `Blocked`, `NOERROR`, `SERVFAIL` and the rest — with the **Route** column beside it saying how the answer was produced, which is the pair the old Status column could not show: a blocked name is answered with NOERROR, and reading only the code made a block look like a success. Both unhappy cases are marked by weight and shape as well as colour. Switching between History and Live keeps your filters, and leaving Live closes the stream instead of holding a viewer slot open. A live row that the log has not written yet opens its own provenance in place — no invented row id — and the open detail stays put while the 500-row buffer scrolls past underneath it. Domain testing moves to `/activity/test` as **Current policy simulation**, worded so it can never be misread as an account of a query that already happened.
|
||||
- **Diagnostics can be scoped to an absolute window.** `/diagnostics?since=…&until=…` now validates and applies both bounds to the active and resolved lists, and the page states the window it is showing with a way to clear it. A query's detail page links here with the five minutes either side of that query, which is where the underlying failure text for a SERVFAIL lives.
|
||||
- **The query log and the 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.
|
||||
- **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
|
||||
|
||||
- **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`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A UDP reply that has to be truncated keeps the answer's RCODE.** When an answer does not fit the client's UDP buffer, nxdns replaces it with an empty reply carrying the TC bit, which tells the client to retry over TCP. That replacement was always built as NOERROR, whatever the answer said — so an oversized NXDOMAIN reached the client as a success, and an EDNS extended RCODE above 15 lost the eight upper bits it needs an OPT record to carry. The truncated reply now carries the full twelve-bit code the answer had, split across the header and the reply's OPT record where the code needs it, and the query-log row records the code the client actually saw. The retry over TCP always returned the right RCODE; this was the UDP answer that preceded it.
|
||||
|
||||
## [0.0.8] - 2026-08-21
|
||||
|
||||
One constant, chosen from the 0.0.7 field numbers: the checkpoint cadence was the last first-order write cost on the Pi's SD card.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The query log checkpoints its write-ahead log every 32 MiB instead of every 4 MiB.** Batching the writer in 0.0.7 took the deployed Pi from about 0.5 to 0.281 GiB of writes a day, and about 130 MiB of what is left is checkpoint writeback: SQLite's 1000-page default trips roughly every 40 minutes and rewrites the same hot index and interior pages into `querylog.db` each time. Every read-write connection to `querylog.db` now sets `wal_autocheckpoint` to 8192 pages, which stretches that to roughly five hours and cuts those in-place rewrites about eightfold, for an expected total near 190 MiB a day. The price is durability under power loss or a kernel panic. At `synchronous = NORMAL` a commit does not fsync, so the checkpoint is the only guaranteed durability boundary, and it now sits about five hours of query rows and upstream-history minutes back rather than 40 minutes. Kernel writeback normally makes the real loss far smaller than that, but nothing guarantees it. A process crash or a clean stop still loses nothing that was committed, and the database is never left inconsistent: recovery replays the longest valid prefix of the log. The `querylog.db-wal` file is expected to sit near 32 MiB rather than capped there, since a long-running reader can hold a checkpoint off and let it overshoot, and the daily retention pass still truncates it. `config.db` is unchanged.
|
||||
|
||||
## [0.0.7] - 2026-08-20
|
||||
|
||||
Operational failures get a page of their own, and the query log stops wearing out the disk it lives on: the deployed Pi was writing half a gigabyte a day to store two megabytes of query rows, one transaction per query. Both came out of running 0.0.6 on real hardware.
|
||||
|
||||
### Added
|
||||
|
||||
- **A diagnostics page.** Operational failures now land in one curated log instead of only journald: blocklist download failures, certificate reload failures, disk pressure, query-log writer and maintenance failures, upstream exchange and history failures, client tracking failures, listener and configuration problems at boot, and the query-log recreation an upgrade causes. One entry per failing subject — an entry opens on the first failure, counts repeats, and closes itself when the subject recovers; nothing needs dismissing. Each entry says what it means for the service and what to do about it. `GET /api/diagnostics` serves the log, `GET /api/health` reports the active counts and degrades while the diagnostics store itself cannot write, and `/metrics` gains `nxdns_diagnostics_active_warnings`, `nxdns_diagnostics_active_errors` and `nxdns_diagnostics_write_failures_total`. Resolved entries can be purged when you decide the history has served its purpose — one entry from its row or its detail page, or the whole resolved history at once with "Purge all resolved" (`DELETE /api/diagnostics/{id}` and `DELETE /api/diagnostics`). An entry that is still failing is the current state of the box, not history, so it has no purge action and the API answers 409.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The query log commits once a minute instead of once a query.** The writer batched for 100 milliseconds, which at a household's query rate means almost every query got a transaction of its own — and a transaction costs the disk far more than the row it carries. On the deployed Pi that came to roughly 0.5 GiB of writes a day to store 2.3 MB of query rows, the kind of write volume that kills an SD card. The batch window is now `logging.query_log_flush_interval_s`: 60 seconds by default (the same minute Pi-hole's `DBinterval` defaults to, for the same reason), anything from 0 to 3600, editable on the settings page. Batches are still capped at 100 rows, so a burst is committed as soon as it fills one rather than waiting out the window, and the in-memory queue, its drop-oldest backpressure and retention are untouched. The price is two kinds of lag: a crash costs about one interval of query history — more if the writer was held back by a full disk or a slow write — and every query-log-backed view — the query-log page, the dashboard totals, the timeseries — is about one interval behind. The live page is not affected; it is fed before the queue. Set the key to `0` for the old write-immediately behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shutdown no longer races the last query rows to the disk.** The query-log writer was stopped by the same cancellation that stopped the DNS listeners, so whether the batch it was holding reached the database depended on which happened to land first, the cancellation or the queue closing. Shutdown now stops and joins the listeners and every other query producer first, then closes the queue, then waits for the writer to finish emptying it — the held batch and everything still queued get written. If free space is below the critical threshold and the disk monitor will not let that final write through, the rows are counted as dropped instead of holding the exit open indefinitely.
|
||||
- **An upstream success rate no longer rounds up to 100.0% while failures stand.** One decimal place cannot hold 12,696 successes out of 12,698 attempts: it rounded to `100.0%`, so the row claimed perfect reliability next to a failure count of 2. Neither end of the scale is reachable by rounding any more — `100.0%` needs an actual absence of failures and `0.0%` an actual absence of successes, and a rate a hair off either end shows `99.9%` or `0.1%` instead.
|
||||
- **A query log set aside by a schema change is no longer named `corrupt`.** Every recreate wrote the old file to `querylog.db.corrupt-<unix seconds>`, whatever sent it there — including the fingerprint mismatch an upgrade causes, where the file is a healthy database this build simply cannot read. The name is the only account of the reason that outlives the log line, so it read as an accusation and invited operators to delete an intact file. The name now says which of the four cases it hit: `querylog.db.corrupt-…`, `.not-a-database-…`, `.quick-check-failed-…` or `.schema-changed-…`. The 0.0.6 upgrade produces `schema-changed`. Nothing else about the recreate changed, and no existing aside file is renamed.
|
||||
|
||||
## [0.0.6] - 2026-08-17
|
||||
|
||||
The period picker now scopes the whole dashboard. The upstream table was the last widget that ignored it, and fixing that meant recording upstream outcomes over time instead of counting them since boot. Read the query-log note below before you upgrade.
|
||||
|
||||
@@ -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.
|
||||
- DNS caching: positive + negative, in-memory only.
|
||||
- Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments.
|
||||
- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, dashboard + time buckets, live SSE stream.
|
||||
- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, Overview + time buckets, live SSE stream.
|
||||
- Web app + REST API: LAN/Tailscale admin UI, optional password auth, OpenAPI schema + CI contract tests.
|
||||
- Observability: upstream health API + UI, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
|
||||
- Observability: `/api/health` conditions surfaced as a Diagnostics badge and health strip, live upstream availability in `/metrics`, a failing upstream as a Diagnostics episode, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
|
||||
- Ops: config authority chosen by the invocation (database, or a file named by `--config`), `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose.
|
||||
|
||||
### 2.2 Out of Scope (permanent scope decisions, not deferrals)
|
||||
@@ -325,7 +325,7 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
|
||||
|
||||
- Schemes: `https://…` → DoH, `tls://host:853` → DoT.
|
||||
- Ordered by priority; sequential attempt; per-upstream failure counters; exponential backoff with jitter; success resets.
|
||||
- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. This is routing state: it drives failover and backoff, and is exposed through `/metrics` and `nxdns check`. `GET /api/upstream/health?period=…` exposes none of it except the live `enabled`/`available` pair; its counts, success rate and last failure are ranged aggregates read from the per-minute upstream history in `querylog.db`, so the dashboard's period scopes them like every other number on the page.
|
||||
- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. This is routing state: it drives failover and backoff, and is exposed through `/metrics` and `nxdns check`. There is no per-upstream API surface: `/api/health` reports the pool as available-of-enabled, and a failing upstream is a Diagnostics episode (`upstream.exchange`).
|
||||
- DoH client: `std.http.Client` with `content-type/accept: application/dns-message`; strict status + payload checks.
|
||||
- `platform/tls_client.zig` enforces per-connection read/write deadlines, classifies TLS errors explicitly, retries with backoff. Integration tests cover timeout/hang scenarios so compiler upgrades can't silently regress them.
|
||||
- Connect, read, and total-budget timeouts each configurable.
|
||||
@@ -435,8 +435,28 @@ CREATE TABLE forward_zones (
|
||||
);
|
||||
|
||||
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
|
||||
CREATE TABLE operational_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
subject_key TEXT NOT NULL,
|
||||
subject_label TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
occurrences INTEGER NOT NULL CHECK (occurrences > 0),
|
||||
resolved_at INTEGER,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
CHECK (resolved_at IS NULL OR resolved_at >= first_seen)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_operational_events_active
|
||||
ON operational_events(code, subject_key) WHERE resolved_at IS NULL;
|
||||
CREATE INDEX idx_operational_events_last_seen
|
||||
ON operational_events(last_seen DESC);
|
||||
```
|
||||
|
||||
`operational_events` is the one table here that is **not** configuration. It is the diagnostics log of `src/storage/events.zig`: one row per failure episode, opened on the first failure and resolved when the same subject succeeds again. It is deliberately absent from `config_schema.table_names` and `config_schema.delete_order`, so `nxdns export` never emits it and `nxdns import` never wipes it.
|
||||
|
||||
### 11.3 querylog.db Schema
|
||||
|
||||
```sql
|
||||
@@ -460,30 +480,12 @@ CREATE TABLE query_log (
|
||||
CREATE INDEX idx_query_log_ts ON query_log(timestamp);
|
||||
CREATE INDEX idx_query_log_client ON query_log(client_ip);
|
||||
CREATE INDEX idx_query_log_domain ON query_log(domain_id);
|
||||
|
||||
CREATE TABLE upstream_targets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
url TEXT NOT NULL UNIQUE -- the historical identity: config.db ids cannot cross database files
|
||||
);
|
||||
|
||||
CREATE TABLE upstream_minute (
|
||||
upstream_id INTEGER NOT NULL REFERENCES upstream_targets(id),
|
||||
minute_ts INTEGER NOT NULL,
|
||||
successes INTEGER NOT NULL,
|
||||
failures INTEGER NOT NULL,
|
||||
last_failure_ts INTEGER,
|
||||
last_error TEXT,
|
||||
PRIMARY KEY (upstream_id, minute_ts),
|
||||
CHECK (successes >= 0),
|
||||
CHECK (failures >= 0)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
|
||||
```
|
||||
|
||||
### 11.4 Query Logger
|
||||
|
||||
- In-memory buffer, mutex guarded, hard cap `query_log_buffer_max` (default 10000).
|
||||
- Flush: batch size (default 100) or max interval (default 100ms).
|
||||
- Flush: batch size (100, comptime) or max interval `query_log_flush_interval_s` (default 60s, 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.
|
||||
- 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.
|
||||
|
||||
@@ -532,7 +534,7 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
|
||||
### 13.1 Endpoints
|
||||
|
||||
- `POST /api/auth/login`, `POST /api/auth/logout`
|
||||
- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…`
|
||||
- `GET /api/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/PUT /api/clients/{id}`
|
||||
- `GET/POST/PUT/DELETE /api/groups…`, `/api/blocklists…`, `/api/rules…`, `/api/local-records…`, `/api/forward-zones…`
|
||||
@@ -540,9 +542,8 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
|
||||
- `GET /api/lookup?domain=…&group_id=…`
|
||||
- `GET/POST /api/pause`
|
||||
- `GET/PUT /api/settings`
|
||||
- `GET /api/upstream/health?period=…`
|
||||
- `POST /api/certs/reload`
|
||||
- `GET /api/health` — overall + disk + upstream + queries_dropped rollup
|
||||
- `GET /api/health` — five condition objects (protection, upstreams, query history, diagnostics, disk) and the status computed from exactly their states
|
||||
- `GET /metrics` — Prometheus text exposition: query counters (total/blocked/cached), per-upstream health, cache stats, queries_dropped, disk gauges
|
||||
- `GET /api/version`, `GET /api/openapi.yaml`
|
||||
|
||||
@@ -554,7 +555,7 @@ Hand-maintained `openapi.yaml`, served at `GET /api/openapi.yaml` and mirrored b
|
||||
|
||||
## 14. Frontend
|
||||
|
||||
Pages: Dashboard (stats + upstream health + disk), Query log, Live log, Clients, Groups, Blocklists, Rules, Local DNS (records + forward zones), Domain lookup, Settings.
|
||||
Pages: Overview (stat tiles, queries over time, client activity over time, and query-type and upstream breakdowns, all over one period), Query log, Live log, Clients, Groups, Blocklists, Rules, Local DNS (records + forward zones), Domain lookup, Settings.
|
||||
|
||||
Requirements: responsive desktop/mobile; route loaders for initial fetch; TanStack Query for cache/retries; error/loading states on every data view; works with auth enabled or disabled; restart-required banner.
|
||||
|
||||
@@ -612,7 +613,7 @@ systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/v
|
||||
- **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON loading + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade).
|
||||
- **Fuzz**: DNS parser malformed-packet fuzzing; blocklist parser fuzzing.
|
||||
- **Integration**: UDP/TCP query path; blocked path; allow-over-block; wildcard precedence; CNAME uncloaking block; local records + forward zones; upstream failover/backoff/health; disk-full degradation; querylog.db corruption recovery; API CRUD; auth on/off; SSE; contract tests.
|
||||
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log.
|
||||
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; Overview + live log.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -87,8 +87,8 @@ test("429 login shows a ticking countdown and keeps submit disabled until it end
|
||||
|
||||
test("safeRedirect only allows same-origin absolute paths", () => {
|
||||
expect(safeRedirect(undefined)).toBe("/");
|
||||
expect(safeRedirect("/queries")).toBe("/queries");
|
||||
expect(safeRedirect("/queries?x=1")).toBe("/queries?x=1");
|
||||
expect(safeRedirect("/activity")).toBe("/activity");
|
||||
expect(safeRedirect("/activity?x=1")).toBe("/activity?x=1");
|
||||
expect(safeRedirect("//evil.example")).toBe("/");
|
||||
expect(safeRedirect("https://evil.example")).toBe("/");
|
||||
expect(safeRedirect("/\\evil.example")).toBe("/");
|
||||
|
||||
@@ -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/queries/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/queries/provenanceFixture";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
id,
|
||||
ip,
|
||||
name,
|
||||
learned_name: learnedName,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: name !== "",
|
||||
first_seen: 1_700_000_000,
|
||||
last_seen: 1_700_000_100,
|
||||
};
|
||||
}
|
||||
|
||||
const CLIENTS: Client[] = [
|
||||
client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||
client(2, "192.0.2.11", "", "laptop.lan"),
|
||||
client(3, "192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||
return queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides });
|
||||
}
|
||||
|
||||
const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 };
|
||||
|
||||
/** The blocked row every page fixture reuses. */
|
||||
const BLOCKED = {
|
||||
blocked: true,
|
||||
policy_action: "block",
|
||||
policy_reason: "blocklist_wildcard",
|
||||
route_kind: "blocked",
|
||||
upstream: "",
|
||||
} as const satisfies Partial<QueryRow>;
|
||||
|
||||
const PAGES: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [
|
||||
row(20, "first.example", { qtype: 65, cache_hit: true, route_kind: "cache" }),
|
||||
row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }),
|
||||
],
|
||||
next_before: 19,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=19": {
|
||||
queries: [row(5, "older.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?domain=ads": {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?domain=ads&blocked=true": {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?since=1700000000": {
|
||||
queries: [row(20, "first.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
/** The shell's own requests, which every test serves the same way. */
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
|
||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||
// 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/queries/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
import { queriesFilterOf, type ActivitySearch } from "./search";
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
marginTop: "1.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||
row: {
|
||||
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
footer: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
refetching: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
moreButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
moreError: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
},
|
||||
});
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export default function HistoryActivity({ search }: { search: ActivitySearch }) {
|
||||
const filter = queriesFilterOf(search);
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
||||
const clientNames = useClientNames();
|
||||
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const coverage = pages[0]?.coverage;
|
||||
const filterActive = Object.keys(filter).length > 0;
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
// screen so the button keeps its place instead of flashing "end of log".
|
||||
const lastPage = pages[pages.length - 1];
|
||||
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
|
||||
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
||||
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
|
||||
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||
void base.fetchNextPage();
|
||||
}
|
||||
|
||||
// The loader starts this fetch but does not wait for it, so both the first
|
||||
// paint and a failed first page are this component's to render.
|
||||
if (base.status === "error" && base.data === undefined) {
|
||||
return <InlineError error={base.error} onRetry={() => void base.refetch()} />;
|
||||
}
|
||||
if (base.data === undefined) {
|
||||
return (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
Loading activity…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{base.isFetching && (
|
||||
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
||||
Loading…
|
||||
</p>
|
||||
)}
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||
{rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||
<ActivityCells
|
||||
row={summarizeRow(row)}
|
||||
clientNames={clientNames}
|
||||
renderDomain={(id, children) =>
|
||||
id === null ? (
|
||||
children
|
||||
) : (
|
||||
<Link
|
||||
to="/activity/queries/$id"
|
||||
params={{ id: String(id) }}
|
||||
search={search}
|
||||
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
{...stylex.props(shared.button, styles.moreButton, shared.focusRing)}
|
||||
>
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moreError !== null && (
|
||||
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||
Failed to load more: {moreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 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 { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Client } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
|
||||
function client(ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
id: Number(ip.split(".").pop()),
|
||||
ip,
|
||||
name,
|
||||
learned_name: learnedName,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: name !== "",
|
||||
first_seen: 1_700_000_000,
|
||||
last_seen: 1_700_000_100,
|
||||
};
|
||||
}
|
||||
|
||||
const CLIENTS: Client[] = [
|
||||
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||
client("192.0.2.11", "", "laptop.lan"),
|
||||
client("192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
let sources: FakeEventSource[];
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
|
||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||
if (url === "/api/clients") return Promise.resolve(json({ clients: CLIENTS }));
|
||||
// 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);
|
||||
});
|
||||
|
||||
test("an open streamed detail survives the row being evicted from the ring buffer", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// 500 more queries: the ring keeps the newest 500, so the selected row is
|
||||
// gone from the table. The detail is a snapshot, not a lookup into the ring.
|
||||
act(() => {
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
||||
}
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an open streamed detail survives Freeze and Resume", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
||||
|
||||
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
|
||||
expect(domain.value).toBe("ads");
|
||||
expect(domain.disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
|
||||
|
||||
const form = domain.closest("form")!;
|
||||
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
for (const control of controls) {
|
||||
// A disabled form control is skipped by the browser's tab order, and RAC
|
||||
// pins its own trigger out of it as well. Nothing in the row may
|
||||
// reintroduce itself with a reachable tabindex.
|
||||
expect(control.hasAttribute("disabled")).toBe(true);
|
||||
const tabindex = control.getAttribute("tabindex");
|
||||
expect(tabindex === null || tabindex === "-1").toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true&since=1700000000&bogus=1");
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
|
||||
expect(queryCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "History" }));
|
||||
await screen.findByRole("button", { name: "Apply filters" });
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
// The filters came along, which is the point of switching rather than
|
||||
// navigating: the reader keeps the question they were asking.
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Live" }));
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(sources[1]!.closed).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
+185
-22
@@ -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 { useClientNames } from "@/features/clients/clientNames";
|
||||
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
||||
import { RING_CAPACITY } from "./ringBuffer";
|
||||
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
||||
import { summarizeEvent } from "@/features/queries/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
import ProvenanceDetail from "./ProvenanceDetail";
|
||||
import RelatedActions from "./RelatedActions";
|
||||
import { RING_CAPACITY, summaryOf, type StreamedRow } from "./ringBuffer";
|
||||
import type { ActivitySearch } from "./search";
|
||||
import { useLiveQueries, type StreamStatus } from "./useLiveQueries";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
toolbar: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
@@ -134,6 +150,40 @@ const styles = stylex.create({
|
||||
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
|
||||
},
|
||||
},
|
||||
/** A streamed row opens its detail here rather than at a route, so it is a button that looks like the link beside it. */
|
||||
domainButton: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
font: "inherit",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
textDecorationLine: "underline",
|
||||
textDecorationStyle: "dotted",
|
||||
},
|
||||
detailPanel: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surface,
|
||||
padding: "1rem",
|
||||
},
|
||||
detailBar: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
footnote: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -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> = {
|
||||
connecting: "Connecting…",
|
||||
open: "Live",
|
||||
@@ -165,16 +219,86 @@ 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). */
|
||||
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
||||
const live = useLiveQueries({ createEventSource });
|
||||
const clientNames = useClientNames();
|
||||
/**
|
||||
* The open detail, held as the selected row itself rather than as a key into
|
||||
* the buffer.
|
||||
*
|
||||
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
||||
* key would go stale under the reader while they were still reading it, and the
|
||||
* panel would blank out for no reason they could see. The snapshot is the whole
|
||||
* fact — a streamed frame carries its own provenance — so it survives eviction,
|
||||
* a merge and a Freeze/Resume, and closes only when the reader closes it or
|
||||
* leaves live mode.
|
||||
*/
|
||||
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
||||
const summary = summarizeEvent(row.event);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The panel opens above the table, behind the trigger in tab order, so a
|
||||
// forward tab from the row would walk past it. Focus moves in on open —
|
||||
// keyed on the row, so choosing a second row moves it again — and the
|
||||
// closer puts it back on the trigger.
|
||||
useEffect(() => {
|
||||
panel.current?.focus();
|
||||
}, [row.key]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
|
||||
const live = useLiveQueries();
|
||||
const clientNames = useClientNames();
|
||||
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
function open(row: StreamedRow, from: HTMLButtonElement) {
|
||||
trigger.current = from;
|
||||
setSelected(row);
|
||||
}
|
||||
|
||||
// The row that opened the panel takes focus back, unless the ring has
|
||||
// already evicted it: a detached button cannot be focused, and the browser
|
||||
// falls back to the document, which is the best available answer.
|
||||
function close() {
|
||||
setSelected(null);
|
||||
trigger.current?.focus();
|
||||
trigger.current = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Live</h1>
|
||||
<StatusPill status={live.status} />
|
||||
<button
|
||||
type="button"
|
||||
@@ -228,6 +352,8 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
|
||||
|
||||
{live.rows.length === 0 ? (
|
||||
live.status !== "capped" && (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
@@ -238,13 +364,50 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<QueryTableHead />
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
{live.rows.map((row) => (
|
||||
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
|
||||
<QueryCells row={row} clientNames={clientNames} />
|
||||
</tr>
|
||||
))}
|
||||
{live.rows.map((row) => {
|
||||
const summary = summaryOf(row);
|
||||
return (
|
||||
<tr
|
||||
key={row.key}
|
||||
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
|
||||
>
|
||||
<ActivityCells
|
||||
row={summary}
|
||||
clientNames={clientNames}
|
||||
renderDomain={(_id, children) =>
|
||||
row.kind === "streamed" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={selected?.key === row.key}
|
||||
aria-controls={
|
||||
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
|
||||
}
|
||||
onClick={(event) => open(row, event.currentTarget)}
|
||||
{...stylex.props(
|
||||
styles.domainButton,
|
||||
activityDomainLink,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/activity/queries/$id"
|
||||
params={{ id: String(row.row.id) }}
|
||||
search={origin}
|
||||
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -254,6 +417,6 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
</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 { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
||||
@@ -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 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 [submitted, setSubmitted] = useState<Submitted | null>(null);
|
||||
const [submitted, setSubmitted] = useState<Submitted | null>(
|
||||
search.domain === undefined ? null : { domain: search.domain, groupId: preselectedGroupId },
|
||||
);
|
||||
|
||||
const lookup = useQuery({
|
||||
...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
|
||||
@@ -275,17 +281,19 @@ export default function LookupPage() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
|
||||
<h1 {...stylex.props(styles.heading)}>Current policy simulation</h1>
|
||||
<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>
|
||||
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||
<div {...stylex.props(styles.domainField)}>
|
||||
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
|
||||
<label htmlFor="policy-test-domain" {...stylex.props(styles.fieldLabel)}>
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
id="lookup-domain"
|
||||
id="policy-test-domain"
|
||||
required
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
@@ -306,10 +314,10 @@ export default function LookupPage() {
|
||||
disabled={lookup.isFetching}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
Look up
|
||||
Simulate
|
||||
</button>
|
||||
</form>
|
||||
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up…</p>}
|
||||
{lookup.isFetching && <p {...stylex.props(styles.note)}>Simulating…</p>}
|
||||
{!lookup.isFetching && lookup.isError && (
|
||||
<p role="alert" {...stylex.props(styles.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/queries/provenanceCopy";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: "0.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** The recorded facts, fenced off from the live links below them. */
|
||||
record: {
|
||||
marginTop: "1rem",
|
||||
maxWidth: "48rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: "1rem",
|
||||
},
|
||||
recordNote: {
|
||||
fontSize: "0.8125rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
section: {
|
||||
marginTop: "1rem",
|
||||
borderTopWidth: { default: 1, ":first-of-type": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
paddingTop: { default: "1rem", ":first-of-type": 0 },
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
facts: {
|
||||
marginTop: "0.5rem",
|
||||
marginBottom: 0,
|
||||
display: "grid",
|
||||
gap: "0.375rem 1rem",
|
||||
gridTemplateColumns: {
|
||||
default: "auto",
|
||||
"@media (min-width: 640px)": "max-content 1fr",
|
||||
},
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
term: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
value: {
|
||||
margin: 0,
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
related: {
|
||||
marginTop: "1.5rem",
|
||||
maxWidth: "48rem",
|
||||
},
|
||||
relatedHeading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
relatedNote: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
relatedList: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
link: {
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
});
|
||||
|
||||
/** The look of one related action, so every caller's links match. */
|
||||
export const provenanceRelatedLink = styles.link;
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div {...stylex.props(styles.section)}>
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>{title}</h2>
|
||||
<dl {...stylex.props(styles.facts)}>{children}</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt {...stylex.props(styles.term)}>{label}</dt>
|
||||
<dd {...stylex.props(styles.value)}>{children}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A recorded name, in the face the rest of the interface gives to names. It
|
||||
* wraps the value rather than the row, so the prose that stands in for a
|
||||
* missing one is not set in the same typewriter face.
|
||||
*/
|
||||
function Mono({ children }: { children: string }) {
|
||||
return <span {...stylex.props(shared.mono)}>{children}</span>;
|
||||
}
|
||||
|
||||
/** An empty text field means the server recorded nothing there, never an empty value. */
|
||||
function Absent({ children }: { children: string }) {
|
||||
return <span {...stylex.props(styles.muted)}>{children}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an empty `matched` means. `no_match` is the only reason the matcher
|
||||
* itself records with nothing to show; every other empty one names a pipeline
|
||||
* step that answered before filtering — a local record, a forward zone, a pause
|
||||
* (see `PolicyReason` in src/storage/provenance.zig) — where "nothing matched"
|
||||
* would claim an evaluation that never happened.
|
||||
*/
|
||||
function unmatchedLabel(reason: PolicyReason): string {
|
||||
return reason === "no_match" ? "Nothing matched" : "The matcher never ran";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
provenance: Provenance;
|
||||
/**
|
||||
* The log row this explains, or null for a frame read straight off the
|
||||
* stream. Null is the same fact `QuerySummary.id` carries: the event
|
||||
* precedes its own insert, so there is no row to name and none is invented.
|
||||
*/
|
||||
persistedId: number | null;
|
||||
/** The related-action links, built by whichever surface owns the routes. */
|
||||
relatedActions: ReactNode;
|
||||
}
|
||||
|
||||
export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) {
|
||||
const { request, group, policy, rewrites, route, response } = provenance;
|
||||
const clientNames = useClientNames();
|
||||
const currentClient = clientLabel(request.client, clientNames);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 {...stylex.props(styles.heading, shared.mono)}>{request.domain}</h1>
|
||||
<p {...stylex.props(styles.subtitle)}>
|
||||
{formatTime(request.time)} — {policyActionLabel(policy.action)}
|
||||
{/* The verdict alone reads as a success; a non-NOERROR answer says otherwise. */}
|
||||
{response.rcode !== 0 && ` — ${rcodeName(response.rcode)}`}
|
||||
</p>
|
||||
|
||||
<div {...stylex.props(styles.record)}>
|
||||
<p {...stylex.props(styles.recordNote)}>
|
||||
What was recorded when this query was answered. Group and blocklist names are the ones in force at
|
||||
that moment; they may have been renamed or deleted since.
|
||||
{persistedId === null &&
|
||||
" This is the event as it was streamed; the query log may not have written it yet."}
|
||||
</p>
|
||||
|
||||
<Section title="Request">
|
||||
<Fact label="Time">{formatTime(request.time)}</Fact>
|
||||
<Fact label="Domain">
|
||||
<Mono>{request.domain}</Mono>
|
||||
</Fact>
|
||||
<Fact label="Client">
|
||||
<Mono>{request.client}</Mono>
|
||||
</Fact>
|
||||
<Fact label="Type">{qtypeName(request.qtype)}</Fact>
|
||||
<Fact label="Class">{qclassName(request.qclass)}</Fact>
|
||||
</Section>
|
||||
|
||||
<Section title="Group">
|
||||
<Fact label="Name">{group.name === "" ? <Absent>No group recorded</Absent> : group.name}</Fact>
|
||||
<Fact label="Id">{group.id === null ? <Absent>—</Absent> : group.id}</Fact>
|
||||
</Section>
|
||||
|
||||
<Section title="Policy">
|
||||
<Fact label="Decision">{policyActionLabel(policy.action)}</Fact>
|
||||
<Fact label="Reason">{policyReasonLabel(policy.reason)}</Fact>
|
||||
<Fact label="Matched">
|
||||
{policy.matched === "" ? (
|
||||
<Absent>{unmatchedLabel(policy.reason)}</Absent>
|
||||
) : (
|
||||
<Mono>{policy.matched}</Mono>
|
||||
)}
|
||||
</Fact>
|
||||
<Fact label="Blocklist">
|
||||
{policy.source_name === "" ? (
|
||||
<Absent>Not a blocklist decision</Absent>
|
||||
) : policy.source_id === null ? (
|
||||
policy.source_name
|
||||
) : (
|
||||
`${policy.source_name} (#${policy.source_id})`
|
||||
)}
|
||||
</Fact>
|
||||
</Section>
|
||||
|
||||
<Section title="Rewrites">
|
||||
<Fact label="CNAME target">
|
||||
{rewrites.cname_target === "" ? (
|
||||
<Absent>The queried name was decided directly</Absent>
|
||||
) : (
|
||||
<Mono>{rewrites.cname_target}</Mono>
|
||||
)}
|
||||
</Fact>
|
||||
<Fact label="Safe search">
|
||||
{rewrites.safe_search_target === "" ? (
|
||||
<Absent>No rewrite</Absent>
|
||||
) : (
|
||||
<Mono>{rewrites.safe_search_target}</Mono>
|
||||
)}
|
||||
</Fact>
|
||||
</Section>
|
||||
|
||||
<Section title="Route">
|
||||
<Fact label="Answered by">{routeKindLabel(route.kind)}</Fact>
|
||||
<Fact label="Forward zone">
|
||||
{route.forward_zone === "" ? <Absent>—</Absent> : <Mono>{route.forward_zone}</Mono>}
|
||||
</Fact>
|
||||
<Fact label="Upstream">
|
||||
{route.upstream === "" ? <Absent>No upstream exchange</Absent> : <Mono>{route.upstream}</Mono>}
|
||||
</Fact>
|
||||
</Section>
|
||||
|
||||
<Section title="Response">
|
||||
<Fact label="Result">{rcodeName(response.rcode)}</Fact>
|
||||
<Fact label="Took">
|
||||
{response.duration_us === null ? (
|
||||
<Absent>Not measured</Absent>
|
||||
) : (
|
||||
formatMicros(response.duration_us)
|
||||
)}
|
||||
</Fact>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* 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/queries/provenanceFixture";
|
||||
import { summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
|
||||
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
|
||||
|
||||
const noNames: ClientNames = new Map();
|
||||
|
||||
function renderRow(overrides: Partial<QueryRow> = {}): HTMLTableRowElement {
|
||||
const row: QuerySummary = summarizeRow(queryRow(1, overrides));
|
||||
render(
|
||||
<table>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
<tr data-testid="row">
|
||||
<ActivityCells
|
||||
row={row}
|
||||
clientNames={noNames}
|
||||
renderDomain={(id, children) => <a href={`/activity/queries/${id}`}>{children}</a>}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
return screen.getByTestId("row") as HTMLTableRowElement;
|
||||
}
|
||||
|
||||
/** The cell under a header, read by its column name rather than its index. */
|
||||
function cell(row: HTMLTableRowElement, column: (typeof ACTIVITY_COLUMNS)[number]): string {
|
||||
const index = ACTIVITY_COLUMNS.indexOf(column);
|
||||
return row.cells[index]?.textContent ?? "";
|
||||
}
|
||||
|
||||
test("the head names the seven columns in order", () => {
|
||||
render(
|
||||
<table>
|
||||
<ActivityTableHead />
|
||||
</table>,
|
||||
);
|
||||
const headers = screen.getAllByRole("columnheader").map((header) => header.textContent);
|
||||
expect(headers).toEqual(["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"]);
|
||||
});
|
||||
|
||||
test("an allowed NOERROR row reads as the answer it got, with no badge", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 0, route_kind: "upstream", response_time_us: 1234 });
|
||||
expect(cell(row, "Result")).toBe("NOERROR");
|
||||
expect(cell(row, "Route")).toBe("Upstream");
|
||||
expect(cell(row, "Duration")).toBe("1.2 ms");
|
||||
expect(cell(row, "Type")).toBe("A");
|
||||
});
|
||||
|
||||
test("a blocked row reads Blocked even though the client got NOERROR", () => {
|
||||
const row = renderRow({ blocked: true, rcode: 0, route_kind: "blocked", policy_reason: "blocklist_domain" });
|
||||
expect(cell(row, "Result")).toBe("Blocked");
|
||||
expect(cell(row, "Route")).toBe("Blocked");
|
||||
});
|
||||
|
||||
test("a SERVFAIL row names the code", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 2, route_kind: "upstream", response_time_us: null });
|
||||
expect(cell(row, "Result")).toBe("SERVFAIL");
|
||||
expect(cell(row, "Duration")).toBe("—");
|
||||
});
|
||||
|
||||
test("a cache hit names the cache as the route", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 0, route_kind: "cache", cache_hit: true, upstream: "" });
|
||||
expect(cell(row, "Route")).toBe("Cache");
|
||||
expect(cell(row, "Result")).toBe("NOERROR");
|
||||
});
|
||||
|
||||
test("an unassigned extended rcode keeps the numeric fallback, without the long form's parentheses", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 3841 });
|
||||
expect(cell(row, "Result")).toBe("RCODE 3841");
|
||||
});
|
||||
|
||||
test("a persisted row links its domain to the detail the caller chose", () => {
|
||||
renderRow({ domain: "ads.example" });
|
||||
expect(screen.getByRole("link", { name: "ads.example" }).getAttribute("href")).toBe("/activity/queries/1");
|
||||
});
|
||||
|
||||
test("a streamed row reaches the renderer with a null id, and can render as plain text", () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr data-testid="row">
|
||||
<ActivityCells
|
||||
row={{ ...summarizeRow(queryRow(1)), id: null }}
|
||||
clientNames={noNames}
|
||||
renderDomain={(id, children) => {
|
||||
expect(id).toBeNull();
|
||||
return children;
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(screen.getByTestId("row").textContent).toContain("example.com");
|
||||
});
|
||||
|
||||
test("every route kind has a compact label", () => {
|
||||
expect(routeLabel("blocked")).toBe("Blocked");
|
||||
expect(routeLabel("local")).toBe("Local");
|
||||
expect(routeLabel("forward_zone")).toBe("Forward zone");
|
||||
expect(routeLabel("upstream")).toBe("Upstream");
|
||||
expect(routeLabel("cache")).toBe("Cache");
|
||||
expect(routeLabel("rejected")).toBe("Rejected");
|
||||
});
|
||||
|
||||
test("resultLabel is the pure form of the Result cell", () => {
|
||||
expect(resultLabel({ blocked: true, rcode: 2 })).toBe("Blocked");
|
||||
expect(resultLabel({ blocked: false, rcode: 5 })).toBe("REFUSED");
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The seven columns of the Activity table: Time, Domain, Client, Type, Result,
|
||||
* Route and Duration.
|
||||
*
|
||||
* The labels here are the compact forms a scanned table needs. The detail page
|
||||
* keeps `provenanceCopy`'s long forms, which spell out the same facts with room
|
||||
* for the rcode number and the "answered by" phrasing.
|
||||
*
|
||||
* The cells render both a stored row and a streamed event, so they know nothing
|
||||
* about routes: the caller renders the Domain cell's contents and decides what,
|
||||
* if anything, a row opens. A streamed row has no id — the frame precedes its
|
||||
* own insert — and only the caller knows whether it has a surface for one.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import type { RouteKind } from "@/lib/types";
|
||||
import { ClientName, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { rcodeShortName } from "@/features/queries/provenanceCopy";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
import type { QuerySummary } from "@/features/queries/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
textAlign: "left",
|
||||
},
|
||||
th: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
nowrap: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
domainLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
/**
|
||||
* The badge shape and its weight are the signal; the tint only says which
|
||||
* kind of unhappy answer this was. A monochrome or colour-blind reading of
|
||||
* the table still separates a blocked or failed row from a plain NOERROR
|
||||
* one, which a hue alone would not.
|
||||
*/
|
||||
badge: {
|
||||
display: "inline-block",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
blockedBadge: {
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||
},
|
||||
faultBadge: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(90.1% 0.076 70.697)" },
|
||||
},
|
||||
});
|
||||
|
||||
const ROUTE_LABELS: Record<RouteKind, string> = {
|
||||
blocked: "Blocked",
|
||||
local: "Local",
|
||||
forward_zone: "Forward zone",
|
||||
upstream: "Upstream",
|
||||
cache: "Cache",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
|
||||
/** The compact Route label. `Record` over the union, so a new kind fails `tsc`. */
|
||||
export function routeLabel(kind: RouteKind): string {
|
||||
return ROUTE_LABELS[kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact Result label. A block is the answer the operator asked nxdns for,
|
||||
* so it wins over the rcode it was delivered as — a blocked name answered with
|
||||
* NOERROR and a zero address is still "Blocked". Everything else reads as the
|
||||
* code the client saw.
|
||||
*/
|
||||
export function resultLabel(row: Pick<QuerySummary, "blocked" | "rcode">): string {
|
||||
return row.blocked ? "Blocked" : rcodeShortName(row.rcode);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row's domain is wrapped in. The caller owns the routes, so it builds
|
||||
* the element; the look stays here, as `activityDomainLink`, which the caller
|
||||
* spreads onto the control itself — a link's own colour beats one inherited
|
||||
* from a wrapper. `id` is null for a streamed row, which history has no surface
|
||||
* for and live opens from memory.
|
||||
*/
|
||||
export type DomainRenderer = (id: number | null, children: ReactNode) => ReactNode;
|
||||
|
||||
export const activityDomainLink = styles.domainLink;
|
||||
|
||||
export function ResultCellContent({ row }: { row: Pick<QuerySummary, "blocked" | "rcode"> }) {
|
||||
const label = resultLabel(row);
|
||||
if (row.blocked) return <span {...stylex.props(styles.badge, styles.blockedBadge)}>{label}</span>;
|
||||
if (row.rcode !== 0) return <span {...stylex.props(styles.badge, styles.faultBadge)}>{label}</span>;
|
||||
return <span {...stylex.props(styles.small, styles.muted)}>{label}</span>;
|
||||
}
|
||||
|
||||
export function ActivityCells({
|
||||
row,
|
||||
clientNames,
|
||||
renderDomain,
|
||||
}: {
|
||||
row: QuerySummary;
|
||||
clientNames: ClientNames;
|
||||
renderDomain: DomainRenderer;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{renderDomain(row.id, row.domain)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
<ResultCellContent row={row} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
|
||||
|
||||
export function ActivityTableHead() {
|
||||
return (
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
{ACTIVITY_COLUMNS.map((column) => (
|
||||
<th key={column} {...stylex.props(styles.th)}>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
datetimeField,
|
||||
datetimeLocalToUnix,
|
||||
editDatetimeField,
|
||||
resolveDatetimeField,
|
||||
unixToDatetimeLocal,
|
||||
} from "./datetime";
|
||||
|
||||
/**
|
||||
* Every assertion here is about local time, so the zone has to be pinned. New
|
||||
* York is the zone the DST cases are written for: the fold is 2024-11-03 01:30
|
||||
* and the gap is 2024-03-10 02:30.
|
||||
*/
|
||||
// The app never reads `process`, so `src` is typed without node's globals; the
|
||||
// test host is node, where assigning `TZ` re-reads the zone for `Date`.
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
const originalTz = process.env["TZ"];
|
||||
beforeAll(() => {
|
||||
process.env["TZ"] = "America/New_York";
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env["TZ"] = originalTz;
|
||||
});
|
||||
|
||||
/** 2024-06-01T12:34:56 EDT. */
|
||||
const SUMMER = 1_717_259_696;
|
||||
|
||||
test("a non-zero-second instant round trips", () => {
|
||||
expect(unixToDatetimeLocal(SUMMER)).toBe("2024-06-01T12:34:56");
|
||||
expect(datetimeLocalToUnix("2024-06-01T12:34:56")).toBe(SUMMER);
|
||||
});
|
||||
|
||||
test("text without seconds parses as :00", () => {
|
||||
expect(datetimeLocalToUnix("2024-06-01T12:34")).toBe(datetimeLocalToUnix("2024-06-01T12:34:00"));
|
||||
});
|
||||
|
||||
test("text that is not a datetime-local value names no instant", () => {
|
||||
expect(datetimeLocalToUnix("")).toBeUndefined();
|
||||
expect(datetimeLocalToUnix("yesterday")).toBeUndefined();
|
||||
expect(datetimeLocalToUnix("2024-06-01")).toBeUndefined();
|
||||
expect(datetimeLocalToUnix("2024-13-01T00:00:00")).toBeUndefined();
|
||||
});
|
||||
|
||||
/** 2024-11-03 01:30 EDT and 01:30 EST: two instants, one wall clock. */
|
||||
const FOLD_FIRST = 1_730_611_800;
|
||||
const FOLD_SECOND = 1_730_615_400;
|
||||
|
||||
test("the fall-back fold gives two instants the same text", () => {
|
||||
expect(unixToDatetimeLocal(FOLD_FIRST)).toBe("2024-11-03T01:30:00");
|
||||
expect(unixToDatetimeLocal(FOLD_SECOND)).toBe("2024-11-03T01:30:00");
|
||||
expect(datetimeLocalToUnix("2024-11-03T01:30:00")).toBe(FOLD_FIRST);
|
||||
});
|
||||
|
||||
test("an untouched fold bound applies the instant it was seeded with, not a re-parse of its text", () => {
|
||||
const field = datetimeField(FOLD_SECOND);
|
||||
expect(field.text).toBe("2024-11-03T01:30:00");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_SECOND });
|
||||
});
|
||||
|
||||
test("an edited fold bound resolves to the first of the two instants, which is what its text says", () => {
|
||||
const field = editDatetimeField(datetimeField(FOLD_SECOND), "2024-11-03T01:30:00");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_FIRST });
|
||||
});
|
||||
|
||||
test("a spring-forward time that exists on no clock is rejected rather than slid forward an hour", () => {
|
||||
const field = editDatetimeField(datetimeField(undefined), "2024-03-10T02:30:00");
|
||||
expect(datetimeLocalToUnix("2024-03-10T02:30:00")).toBe(1_710_055_800);
|
||||
expect(unixToDatetimeLocal(1_710_055_800)).toBe("2024-03-10T03:30:00");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "nonexistent" });
|
||||
});
|
||||
|
||||
test("an edited bound round trips with non-zero seconds", () => {
|
||||
const field = editDatetimeField(datetimeField(undefined), "2024-06-01T12:34:56");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: SUMMER });
|
||||
});
|
||||
|
||||
test("clearing an edited bound drops the filter", () => {
|
||||
const field = editDatetimeField(datetimeField(SUMMER), "");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
|
||||
test("an unparseable edit is reported, never silently dropped", () => {
|
||||
const field = editDatetimeField(datetimeField(undefined), "2024-06-32T99:99");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "unparseable" });
|
||||
});
|
||||
|
||||
test("an unset bound seeds an empty field that stays unset", () => {
|
||||
const field = datetimeField(undefined);
|
||||
expect(field.text).toBe("");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
|
||||
test("a fractional part on the seconds is parsed and dropped, not rejected", () => {
|
||||
// jsdom, and any engine that sanitizes to the full grammar, hands the input
|
||||
// back with milliseconds attached; a bound is a whole second either way.
|
||||
const field = editDatetimeField(datetimeField(undefined), "2023-11-14T23:13:37.000");
|
||||
const resolved = resolveDatetimeField(field);
|
||||
expect(resolved).toEqual({ ok: true, value: datetimeLocalToUnix("2023-11-14T23:13:37") });
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The bridge between a `datetime-local` input and the unix seconds the URL and
|
||||
* the API speak.
|
||||
*
|
||||
* Local wall-clock text is lossy in a way unix seconds are not. Twice a year a
|
||||
* fall-back fold gives two instants the same text, and a spring-forward gap
|
||||
* gives an hour of text no instant at all. So the text is never the authority:
|
||||
* a bound the operator did not touch is carried through as the number it
|
||||
* already was, and a bound they did edit is accepted only when it survives a
|
||||
* round trip unchanged.
|
||||
*/
|
||||
|
||||
/** Zero-padded to the width the `datetime-local` grammar requires. */
|
||||
function pad(value: number, width: number): string {
|
||||
return String(value).padStart(width, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix seconds → the local wall-clock text a `datetime-local` input holds,
|
||||
* always with seconds, because the inputs run at `step={1}`.
|
||||
*/
|
||||
export function unixToDatetimeLocal(unix: number): string {
|
||||
const date = new Date(unix * 1000);
|
||||
const day = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`;
|
||||
const time = `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}`;
|
||||
return `${day}T${time}`;
|
||||
}
|
||||
|
||||
const DATETIME_LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?$/;
|
||||
|
||||
/**
|
||||
* The text with its seconds spelled out, or undefined when it is not a
|
||||
* `datetime-local` value at all. A browser omits `:00` seconds even at
|
||||
* `step={1}`, so the canonical form is what a round trip compares against.
|
||||
*
|
||||
* The grammar allows a fractional part after the seconds and some engines emit
|
||||
* one; a bound is a whole second here and on the wire, so it is parsed and then
|
||||
* dropped rather than treated as text we do not recognise.
|
||||
*/
|
||||
function canonicalize(value: string): string | undefined {
|
||||
const match = DATETIME_LOCAL.exec(value);
|
||||
if (match === null) return undefined;
|
||||
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6] ?? "00"}`;
|
||||
}
|
||||
|
||||
/** Local wall-clock text → unix seconds, or undefined when it names no instant. */
|
||||
export function datetimeLocalToUnix(value: string): number | undefined {
|
||||
const canonical = canonicalize(value);
|
||||
if (canonical === undefined) return undefined;
|
||||
const ms = new Date(canonical).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bound of the filter form: what the input shows, what the applied search
|
||||
* carried, and whether the operator has touched it since.
|
||||
*/
|
||||
export interface DatetimeField {
|
||||
text: string;
|
||||
/** The applied value this field was seeded from, reused while `dirty` is false. */
|
||||
original: number | undefined;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export type DatetimeResolution =
|
||||
{ ok: true; value: number | undefined } | { ok: false; reason: "unparseable" | "nonexistent" };
|
||||
|
||||
export function datetimeField(original: number | undefined): DatetimeField {
|
||||
return { text: original === undefined ? "" : unixToDatetimeLocal(original), original, dirty: false };
|
||||
}
|
||||
|
||||
export function editDatetimeField(field: DatetimeField, text: string): DatetimeField {
|
||||
return { ...field, text, dirty: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The unix value this bound applies.
|
||||
*
|
||||
* An untouched field resolves to the number it was seeded with, never to a
|
||||
* re-parse of its own text: the text of a fall-back instant names two of them,
|
||||
* and re-parsing would silently move a bound the operator never edited.
|
||||
*
|
||||
* An edited field is parsed, then formatted back. A wall-clock time inside the
|
||||
* spring-forward gap exists on no clock, and `Date` quietly slides it forward
|
||||
* an hour; the round trip catches that and the caller reports it instead of
|
||||
* filtering on an hour nobody asked for.
|
||||
*/
|
||||
export function resolveDatetimeField(field: DatetimeField): DatetimeResolution {
|
||||
if (!field.dirty) return { ok: true, value: field.original };
|
||||
if (field.text.trim() === "") return { ok: true, value: undefined };
|
||||
const unix = datetimeLocalToUnix(field.text);
|
||||
if (unix === undefined) return { ok: false, reason: "unparseable" };
|
||||
if (unixToDatetimeLocal(unix) !== canonicalize(field.text)) return { ok: false, reason: "nonexistent" };
|
||||
return { ok: true, value: unix };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The absolute bounds an investigation link carries.
|
||||
*
|
||||
* Every link out of a query detail is time-scoped on purpose: a relative window
|
||||
* would answer a different question tomorrow than it does today, and the whole
|
||||
* point of linking to an episode is that the link keeps showing that episode.
|
||||
*/
|
||||
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
/** The five-minute window the redesign puts around one query. */
|
||||
export const RELATED_WINDOW_SECONDS = 300;
|
||||
|
||||
export interface Bounds {
|
||||
since: number;
|
||||
until: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounds for "all activity for this domain/client", decided per bound.
|
||||
*
|
||||
* When the reader arrived from a bounded investigation, that bound is the one
|
||||
* they are working in and it carries over. A bound they never set falls back to
|
||||
* the five-minute window around this query — never to no bound at all, which
|
||||
* would answer with the whole retained history and lose the episode in it. The
|
||||
* two bounds are decided separately, so a half-bounded origin keeps its half.
|
||||
*/
|
||||
export function relatedBounds(ts: number, origin: Pick<ActivitySearch, "since" | "until">): Bounds {
|
||||
return {
|
||||
since: origin.since ?? ts - RELATED_WINDOW_SECONDS,
|
||||
until: origin.until ?? ts + RELATED_WINDOW_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Diagnostics window around one query. Fixed at five minutes either side of
|
||||
* the query, not inherited: the reader is asking what else was failing while
|
||||
* this query was answered, which is a question about the query's own moment.
|
||||
*/
|
||||
export function diagnosticsBounds(ts: number): Bounds {
|
||||
return { since: ts - RELATED_WINDOW_SECONDS, until: ts + RELATED_WINDOW_SECONDS };
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { Provenance, QueryRow } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { RING_CAPACITY, mergeGap, pushRow, summaryOf, type LiveRow } from "./ringBuffer";
|
||||
|
||||
function streamed(key: number, ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): LiveRow {
|
||||
return {
|
||||
kind: "streamed",
|
||||
key,
|
||||
event: provenance({
|
||||
...sections,
|
||||
request: { time: ts, domain, ...sections.request },
|
||||
route: { upstream: "udp://9.9.9.9:53", ...sections.route },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchedRow(id: number, ts: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||
return queryRow(id, { ts, domain, upstream: "udp://9.9.9.9:53", ...overrides });
|
||||
}
|
||||
|
||||
function counter(start = 100): () => number {
|
||||
let n = start;
|
||||
return () => ++n;
|
||||
}
|
||||
|
||||
function domains(rows: LiveRow[]): string[] {
|
||||
return rows.map((row) => summaryOf(row).domain);
|
||||
}
|
||||
|
||||
describe("summaryOf", () => {
|
||||
test("a streamed frame projects every summary field from the provenance it carries", () => {
|
||||
const event: Provenance = provenance({
|
||||
request: { time: 1700, domain: "ads.example", client: "192.0.2.11", qtype: 28 },
|
||||
policy: { action: "block", reason: "blocklist_wildcard" },
|
||||
route: { kind: "blocked", upstream: "" },
|
||||
response: { duration_us: 42 },
|
||||
});
|
||||
expect(summaryOf({ kind: "streamed", key: 1, event })).toEqual({
|
||||
id: null,
|
||||
ts: 1700,
|
||||
domain: "ads.example",
|
||||
client_ip: "192.0.2.11",
|
||||
qtype: 28,
|
||||
blocked: true,
|
||||
policy_reason: "blocklist_wildcard",
|
||||
rcode: 0,
|
||||
route_kind: "blocked",
|
||||
response_time_us: 42,
|
||||
cache_hit: null,
|
||||
upstream: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("a recovered row projects its stored fields and keeps its id", () => {
|
||||
const row = queryRow(77, { domain: "news.example", cache_hit: true, policy_reason: "rule_allow_exact" });
|
||||
expect(summaryOf({ kind: "recovered", key: 2, row })).toMatchObject({
|
||||
id: 77,
|
||||
domain: "news.example",
|
||||
cache_hit: true,
|
||||
policy_reason: "rule_allow_exact",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The guard the discriminated union exists for: a field added to the wire
|
||||
* DTO must be either projected into the summary or consciously left to the
|
||||
* detail page. A silent addition fails here rather than going unrendered.
|
||||
*/
|
||||
test("every provenance field is either projected or knowingly detail-only", () => {
|
||||
const projected = [
|
||||
"request.time",
|
||||
"request.domain",
|
||||
"request.client",
|
||||
"request.qtype",
|
||||
"policy.action",
|
||||
"policy.reason",
|
||||
"route.kind",
|
||||
"route.upstream",
|
||||
"response.duration_us",
|
||||
];
|
||||
const detailOnly = [
|
||||
"request.qclass",
|
||||
"group.id",
|
||||
"group.name",
|
||||
"policy.matched",
|
||||
"policy.source_id",
|
||||
"policy.source_name",
|
||||
"rewrites.cname_target",
|
||||
"rewrites.safe_search_target",
|
||||
"route.forward_zone",
|
||||
"response.rcode",
|
||||
];
|
||||
const leaves = Object.entries(provenance()).flatMap(([section, fields]) =>
|
||||
Object.keys(fields as Record<string, unknown>).map((field) => `${section}.${field}`),
|
||||
);
|
||||
expect(leaves.sort()).toEqual([...projected, ...detailOnly].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushRow", () => {
|
||||
test("prepends newest-first", () => {
|
||||
let rows: LiveRow[] = [];
|
||||
rows = pushRow(rows, streamed(1, 10, "a.example"));
|
||||
rows = pushRow(rows, streamed(2, 11, "b.example"));
|
||||
expect(domains(rows)).toEqual(["b.example", "a.example"]);
|
||||
});
|
||||
|
||||
test("drops the oldest beyond capacity", () => {
|
||||
let rows: LiveRow[] = [];
|
||||
for (let i = 0; i < 5; i++) rows = pushRow(rows, streamed(i, i, `d${i}.example`), 3);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.key)).toEqual([4, 3, 2]);
|
||||
});
|
||||
|
||||
test("default capacity is 500", () => {
|
||||
let rows: LiveRow[] = [];
|
||||
for (let i = 0; i < RING_CAPACITY + 10; i++) rows = pushRow(rows, streamed(i, i, "x.example"));
|
||||
expect(rows).toHaveLength(RING_CAPACITY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeGap", () => {
|
||||
test("skips rows already in the buffer and counts only new ones", () => {
|
||||
const buffer = [streamed(2, 100, "seen.example"), streamed(1, 99, "old.example")];
|
||||
const fetched = [
|
||||
fetchedRow(30, 102, "gap2.example"),
|
||||
fetchedRow(29, 101, "gap1.example"),
|
||||
fetchedRow(28, 100, "seen.example"),
|
||||
];
|
||||
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||
expect(missed).toBe(2);
|
||||
expect(domains(rows)).toEqual(["gap2.example", "gap1.example", "seen.example", "old.example"]);
|
||||
});
|
||||
|
||||
test("no additions returns the buffer unchanged with missed 0", () => {
|
||||
const buffer = [streamed(1, 100, "seen.example")];
|
||||
const { rows, missed } = mergeGap(buffer, [fetchedRow(5, 100, "seen.example")], counter());
|
||||
expect(missed).toBe(0);
|
||||
expect(rows).toBe(buffer);
|
||||
});
|
||||
|
||||
/**
|
||||
* A household repeats itself: one client, one name, three lookups inside the
|
||||
* same second. The stream delivered one of them before the connection broke,
|
||||
* so the gap fetch must recover the other two rather than let the one row in
|
||||
* the buffer stand for all three.
|
||||
*/
|
||||
test("repeated identical queries drop only as many rows as the buffer already holds", () => {
|
||||
const buffer = [streamed(1, 100, "dup.example")];
|
||||
const fetched = [
|
||||
fetchedRow(12, 100, "dup.example"),
|
||||
fetchedRow(11, 100, "dup.example"),
|
||||
fetchedRow(10, 100, "dup.example"),
|
||||
];
|
||||
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||
expect(missed).toBe(2);
|
||||
expect(domains(rows)).toEqual(["dup.example", "dup.example", "dup.example"]);
|
||||
const recoveredIds = rows.flatMap((row) => (row.kind === "recovered" ? [row.row.id] : []));
|
||||
expect(new Set(recoveredIds).size).toBe(2);
|
||||
});
|
||||
|
||||
test("a gap fetch that repeats the whole buffer adds nothing", () => {
|
||||
const buffer = [streamed(2, 100, "dup.example"), streamed(1, 100, "dup.example")];
|
||||
const fetched = [fetchedRow(12, 100, "dup.example"), fetchedRow(11, 100, "dup.example")];
|
||||
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||
expect(missed).toBe(0);
|
||||
expect(rows).toBe(buffer);
|
||||
});
|
||||
|
||||
/**
|
||||
* Two queries of the same name from the same client in the same second are
|
||||
* still separate facts when any stored column differs — the record type or
|
||||
* class, the response code, the policy that decided them, how long they took,
|
||||
* the route taken. The gap fetch here returns the differing row *first* and
|
||||
* the one the buffer already holds second, so an identity blind to the column
|
||||
* would let the differing row consume the buffered occurrence: the buffered
|
||||
* query would come back duplicated and the other would vanish, at an
|
||||
* unchanged `missed`. Order is what exposes that — the count alone is 1
|
||||
* either way.
|
||||
*
|
||||
* `blocked` and `cache_hit` have no case of their own: the server derives
|
||||
* them from `policy_action` and `route_kind`, so they cannot differ while
|
||||
* everything else holds, and the two columns they follow are covered here.
|
||||
*/
|
||||
test.each([
|
||||
{ column: "qtype", sections: { request: { qtype: 1 } }, held: { qtype: 1 }, differing: { qtype: 28 } },
|
||||
{ column: "qclass", sections: { request: { qclass: 1 } }, held: { qclass: 1 }, differing: { qclass: 3 } },
|
||||
{ column: "rcode", sections: { response: { rcode: 0 } }, held: { rcode: 0 }, differing: { rcode: 2 } },
|
||||
{
|
||||
column: "response_time_us",
|
||||
sections: { response: { duration_us: 1234 } },
|
||||
held: { response_time_us: 1234 },
|
||||
differing: { response_time_us: 9999 },
|
||||
},
|
||||
{
|
||||
column: "route_kind",
|
||||
sections: { route: { kind: "upstream" } },
|
||||
held: { route_kind: "upstream" },
|
||||
differing: { route_kind: "forward_zone" },
|
||||
},
|
||||
{
|
||||
column: "policy_action",
|
||||
sections: { policy: { action: "allow" } },
|
||||
held: { policy_action: "allow" },
|
||||
differing: { policy_action: "not_evaluated" },
|
||||
},
|
||||
{
|
||||
column: "policy_reason",
|
||||
sections: { policy: { reason: "no_match" } },
|
||||
held: { policy_reason: "no_match" },
|
||||
differing: { policy_reason: "rule_allow_exact" },
|
||||
},
|
||||
] satisfies readonly {
|
||||
column: string;
|
||||
sections: Parameters<typeof provenance>[0];
|
||||
held: Partial<QueryRow>;
|
||||
differing: Partial<QueryRow>;
|
||||
}[])("rows differing only in $column survive the gap merge", ({ sections, held, differing }) => {
|
||||
const buffer = [streamed(1, 100, "dual.example", sections)];
|
||||
const fetched = [fetchedRow(6, 100, "dual.example", differing), fetchedRow(5, 100, "dual.example", held)];
|
||||
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||
expect(missed).toBe(1);
|
||||
expect(rows).toEqual([{ kind: "recovered", key: expect.any(Number), row: fetched[0] }, buffer[0]]);
|
||||
});
|
||||
|
||||
test("recovered rows keep their id and take a fresh key", () => {
|
||||
const { rows } = mergeGap([], [fetchedRow(77, 100, "gap.example")], counter(200));
|
||||
const recovered = rows[0];
|
||||
expect(recovered?.key).toBe(201);
|
||||
expect(recovered?.kind).toBe("recovered");
|
||||
expect(recovered !== undefined && recovered.kind === "recovered" ? recovered.row.id : null).toBe(77);
|
||||
});
|
||||
|
||||
test("result is capped at capacity, keeping the newest", () => {
|
||||
const buffer = [streamed(3, 300, "live.example")];
|
||||
const fetched = [fetchedRow(2, 302, "g2.example"), fetchedRow(1, 301, "g1.example")];
|
||||
const { rows, missed } = mergeGap(buffer, fetched, counter(), 2);
|
||||
expect(missed).toBe(2);
|
||||
expect(domains(rows)).toEqual(["g2.example", "g1.example"]);
|
||||
});
|
||||
|
||||
test("merged rows stay sorted newest-first by ts", () => {
|
||||
const buffer = [streamed(4, 105, "after-reopen.example"), streamed(3, 100, "before.example")];
|
||||
const fetched = [fetchedRow(9, 103, "gap.example")];
|
||||
const { rows } = mergeGap(buffer, fetched, counter());
|
||||
expect(rows.map((row) => summaryOf(row).ts)).toEqual([105, 103, 100]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
|
||||
import { summarizeEvent, summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
|
||||
|
||||
/**
|
||||
* A row in the live buffer. `key` is a client-side monotonic counter, because
|
||||
* neither arm has a stable identity of its own on arrival.
|
||||
*
|
||||
* The two arms are genuinely different facts, not two encodings of one. A
|
||||
* streamed frame carries the full provenance of a query the server has not
|
||||
* written yet; a row recovered by the reconnect gap-fetch is the stored summary
|
||||
* of a query that *was* written, and cannot fabricate the provenance it never
|
||||
* received. Only the recovered arm has a row id to link to.
|
||||
*/
|
||||
export type LiveRow = { key: number } & (
|
||||
{ kind: "streamed"; event: LiveQueryEvent } | { kind: "recovered"; row: QueryRow }
|
||||
);
|
||||
|
||||
/** The arm that carries its own provenance, and so its own detail surface. */
|
||||
export type StreamedRow = Extract<LiveRow, { kind: "streamed" }>;
|
||||
|
||||
/** The flat cells both arms render, and the shared identity for gap dedupe. */
|
||||
export function summaryOf(row: LiveRow): QuerySummary {
|
||||
return row.kind === "streamed" ? summarizeEvent(row.event) : summarizeRow(row.row);
|
||||
}
|
||||
|
||||
export const RING_CAPACITY = 500;
|
||||
|
||||
/** Prepend `row` (rows are newest-first) and drop the oldest beyond `capacity`. */
|
||||
export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_CAPACITY): LiveRow[] {
|
||||
const next = [row, ...rows];
|
||||
return next.length > capacity ? next.slice(0, capacity) : next;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the gap merge compares two queries by: the whole stored row bar its id.
|
||||
*
|
||||
* `since` on GET /api/queries is inclusive, so the re-sync fetch returns the
|
||||
* last-seen row(s) again and the merge has to recognise them. The id cannot
|
||||
* serve as the identity — a streamed frame precedes its own insert and has none
|
||||
* — so the comparison is by value, and every stored column has to take part.
|
||||
* Two queries alike in name, client and second but differing in class, rcode,
|
||||
* the policy that decided them or the route taken are separate facts; if they
|
||||
* hashed alike, the fetched row that does *not* match the buffered one would
|
||||
* consume its occurrence, duplicating one query and losing the other.
|
||||
*
|
||||
* `QuerySummary` is the wrong basis for that: it is what the table renders, and
|
||||
* it drops qclass and policy_action. `Omit<QueryRow, "id">`
|
||||
* instead makes the compiler demand a derivation for every stored column, so a
|
||||
* column added to the row cannot quietly fall out of the identity.
|
||||
*/
|
||||
type GapIdentity = Omit<QueryRow, "id">;
|
||||
|
||||
function identityOfRow(row: QueryRow): GapIdentity {
|
||||
return {
|
||||
ts: row.ts,
|
||||
domain: row.domain,
|
||||
client_ip: row.client_ip,
|
||||
qtype: row.qtype,
|
||||
qclass: row.qclass,
|
||||
rcode: row.rcode,
|
||||
blocked: row.blocked,
|
||||
response_time_us: row.response_time_us,
|
||||
cache_hit: row.cache_hit,
|
||||
upstream: row.upstream,
|
||||
policy_action: row.policy_action,
|
||||
policy_reason: row.policy_reason,
|
||||
route_kind: row.route_kind,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same identity out of a live frame, which carries every stored column in
|
||||
* its provenance. The columns the server derives rather than sends — `blocked`
|
||||
* and `cache_hit` — come through `summarizeEvent` so that derivation keeps
|
||||
* living in exactly one place.
|
||||
*/
|
||||
function identityOfEvent(event: LiveQueryEvent): GapIdentity {
|
||||
const summary = summarizeEvent(event);
|
||||
return {
|
||||
ts: summary.ts,
|
||||
domain: summary.domain,
|
||||
client_ip: summary.client_ip,
|
||||
qtype: summary.qtype,
|
||||
qclass: event.request.qclass,
|
||||
rcode: event.response.rcode,
|
||||
blocked: summary.blocked,
|
||||
response_time_us: summary.response_time_us,
|
||||
cache_hit: summary.cache_hit,
|
||||
upstream: summary.upstream,
|
||||
policy_action: event.policy.action,
|
||||
policy_reason: summary.policy_reason,
|
||||
route_kind: event.route.kind,
|
||||
};
|
||||
}
|
||||
|
||||
function identityOf(row: LiveRow): GapIdentity {
|
||||
return row.kind === "streamed" ? identityOfEvent(row.event) : identityOfRow(row.row);
|
||||
}
|
||||
|
||||
/** Sorted keys so the hash cannot depend on the order the two arms happen to build their literals in. */
|
||||
function signature(identity: GapIdentity): string {
|
||||
return JSON.stringify(identity, Object.keys(identity).sort());
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times each signature is already in the buffer. A signature is not
|
||||
* unique: one client asking for one name twice within the same second is an
|
||||
* ordinary household pattern, and the two queries are separate facts. Counting
|
||||
* the occurrences lets the merge drop exactly as many fetched rows as the
|
||||
* buffer already holds, instead of letting one buffered row hide all of them.
|
||||
*/
|
||||
function occurrences(rows: LiveRow[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const key = signature(identityOf(row));
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge rows fetched for a reconnect gap (newest-first, from GET /api/queries)
|
||||
* into the buffer. Each fetched row consumes one buffered occurrence of its
|
||||
* signature and is skipped; the rest are genuinely missed and `missed` counts
|
||||
* them. The result stays newest-first (stable sort by ts) and capped.
|
||||
*/
|
||||
export function mergeGap(
|
||||
rows: LiveRow[],
|
||||
fetched: QueryRow[],
|
||||
nextKey: () => number,
|
||||
capacity: number = RING_CAPACITY,
|
||||
): { rows: LiveRow[]; missed: number } {
|
||||
const buffered = occurrences(rows);
|
||||
const added: LiveRow[] = [];
|
||||
for (const row of fetched) {
|
||||
const key = signature(identityOfRow(row));
|
||||
const count = buffered.get(key) ?? 0;
|
||||
if (count > 0) {
|
||||
buffered.set(key, count - 1);
|
||||
continue;
|
||||
}
|
||||
added.push({ kind: "recovered", row, key: nextKey() });
|
||||
}
|
||||
if (added.length === 0) return { rows, missed: 0 };
|
||||
const merged = [...added, ...rows].sort((a, b) => summaryOf(b).ts - summaryOf(a).ts).slice(0, capacity);
|
||||
return { rows: merged, missed: added.length };
|
||||
}
|
||||
@@ -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 { ApiError } from "@/lib/api";
|
||||
import type { LiveQueryEvent, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import type { QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { summaryOf, type LiveRow } from "./ringBuffer";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
|
||||
|
||||
@@ -8,41 +10,28 @@ afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubLocationAssign() {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/live", search: "", assign });
|
||||
vi.stubGlobal("location", { pathname: "/activity", search: "?mode=live", assign });
|
||||
return assign;
|
||||
}
|
||||
|
||||
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
|
||||
const payload: LiveQueryEvent = {
|
||||
ts,
|
||||
domain,
|
||||
client_ip: "192.0.2.10",
|
||||
qtype: 1,
|
||||
blocked: false,
|
||||
block_reason: "",
|
||||
response_time_us: 500,
|
||||
cache_hit: false,
|
||||
upstream: "udp://9.9.9.9:53",
|
||||
...overrides,
|
||||
};
|
||||
function frame(ts: number, domain: string): { data: string } {
|
||||
const payload = provenance({
|
||||
request: { time: ts, domain },
|
||||
route: { upstream: "udp://9.9.9.9:53" },
|
||||
});
|
||||
return { data: JSON.stringify(payload) };
|
||||
}
|
||||
|
||||
function fetchedRow(id: number, ts: number, domain: string): QueryRow {
|
||||
return {
|
||||
id,
|
||||
ts,
|
||||
domain,
|
||||
client_ip: "192.0.2.10",
|
||||
qtype: 1,
|
||||
blocked: false,
|
||||
block_reason: "",
|
||||
response_time_us: 500,
|
||||
cache_hit: false,
|
||||
upstream: "udp://9.9.9.9:53",
|
||||
};
|
||||
return queryRow(id, { ts, domain, upstream: "udp://9.9.9.9:53" });
|
||||
}
|
||||
|
||||
function domains(rows: LiveRow[]): string[] {
|
||||
return rows.map((row) => summaryOf(row).domain);
|
||||
}
|
||||
|
||||
const FULL_COVERAGE = { complete: true, available_since: 0 };
|
||||
|
||||
function setup(fetchSince?: (since: number) => Promise<QueriesPage>, probeSession?: () => Promise<unknown>) {
|
||||
const sources: FakeEventSource[] = [];
|
||||
const createEventSource = (url: string) => {
|
||||
@@ -68,7 +57,7 @@ test("open then frames: rows newest-first with increasing keys", () => {
|
||||
sources[0]!.emit("query", frame(1001, "b.example"));
|
||||
});
|
||||
const rows = hook.result.current.rows;
|
||||
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
|
||||
expect(domains(rows)).toEqual(["b.example", "a.example"]);
|
||||
expect(rows[0]!.key).toBeGreaterThan(rows[1]!.key);
|
||||
});
|
||||
|
||||
@@ -87,6 +76,7 @@ test("error then reopen re-syncs the gap since the last seen ts", async () => {
|
||||
return Promise.resolve({
|
||||
queries: [fetchedRow(9, 1002, "gap.example"), fetchedRow(8, since, "a.example")],
|
||||
next_before: null,
|
||||
coverage: FULL_COVERAGE,
|
||||
});
|
||||
});
|
||||
const { sources, hook } = setup(fetchSince);
|
||||
@@ -103,7 +93,7 @@ test("error then reopen re-syncs the gap since the last seen ts", async () => {
|
||||
expect(fetchSince).toHaveBeenCalledWith(1000);
|
||||
|
||||
await waitFor(() => expect(hook.result.current.missed).toBe(1));
|
||||
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["gap.example", "a.example"]);
|
||||
expect(domains(hook.result.current.rows)).toEqual(["gap.example", "a.example"]);
|
||||
|
||||
act(() => hook.result.current.dismissMissed());
|
||||
expect(hook.result.current.missed).toBeNull();
|
||||
@@ -127,7 +117,7 @@ test("a 401 gap re-sync redirects to login instead of setting resyncFailed", asy
|
||||
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||
act(() => sources[0]!.emit("error"));
|
||||
act(() => sources[0]!.emit("open"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||
expect(hook.result.current.resyncFailed).toBe(false);
|
||||
});
|
||||
|
||||
@@ -152,7 +142,7 @@ test("cap trip with an expired session redirects to login", async () => {
|
||||
act(() => {
|
||||
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||
});
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -193,7 +183,7 @@ test("a fatal rejection with an expired session redirects to login", async () =>
|
||||
|
||||
act(() => sources[0]!.failFatal());
|
||||
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -235,12 +225,12 @@ test("freeze keeps the display fixed while the buffer keeps filling", () => {
|
||||
sources[0]!.emit("query", frame(1001, "b.example"));
|
||||
sources[0]!.emit("query", frame(1002, "c.example"));
|
||||
});
|
||||
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["a.example"]);
|
||||
expect(domains(hook.result.current.rows)).toEqual(["a.example"]);
|
||||
expect(hook.result.current.liveCount).toBe(3);
|
||||
|
||||
act(() => hook.result.current.toggleFreeze());
|
||||
expect(hook.result.current.frozen).toBe(false);
|
||||
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["c.example", "b.example", "a.example"]);
|
||||
expect(domains(hook.result.current.rows)).toEqual(["c.example", "b.example", "a.example"]);
|
||||
});
|
||||
|
||||
test("stale sources are ignored after retry and closed on unmount", () => {
|
||||
+6
-2
@@ -121,8 +121,12 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
lastSeenTsRef.current = payload.ts;
|
||||
bufferRef.current = pushRow(bufferRef.current, { ...payload, key: ++keyRef.current });
|
||||
lastSeenTsRef.current = payload.request.time;
|
||||
bufferRef.current = pushRow(bufferRef.current, {
|
||||
kind: "streamed",
|
||||
event: payload,
|
||||
key: ++keyRef.current,
|
||||
});
|
||||
setRows(bufferRef.current);
|
||||
});
|
||||
|
||||
@@ -37,17 +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);
|
||||
if (client === undefined || (client.name === "" && client.learned_name === "")) {
|
||||
return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
||||
}
|
||||
if (client === undefined) return null;
|
||||
if (client.name !== "") return { text: client.name, learned: false };
|
||||
if (client.learned_name !== "") return { text: client.learned_name, learned: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
|
||||
const label = clientLabel(ip, names);
|
||||
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
||||
// The name replaces the address on screen, so the address stays reachable
|
||||
// as the tooltip rather than disappearing from the row entirely.
|
||||
if (client.name !== "") return <span title={ip}>{client.name}</span>;
|
||||
return (
|
||||
<span title={ip} {...stylex.props(shared.learnedName)}>
|
||||
{client.learned_name}
|
||||
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
|
||||
{label.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
/** Wall clock at import; the upstream fixtures date their failures against it. */
|
||||
const NOW_S = Math.floor(Date.now() / 1000);
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"/api/stats?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
queries: 1000,
|
||||
blocked: 250,
|
||||
cached: 100,
|
||||
clients: 7,
|
||||
avg_response_time_us: 2345,
|
||||
},
|
||||
"/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?period=24h": {
|
||||
period: "24h",
|
||||
since: NOW_S - 86_400,
|
||||
until: NOW_S,
|
||||
available: 1,
|
||||
total: 2,
|
||||
complete: true,
|
||||
upstreams: [
|
||||
{
|
||||
url: "https://dns.example/dns-query",
|
||||
enabled: true,
|
||||
available: false,
|
||||
period: {
|
||||
attempts: 100,
|
||||
successes: 90,
|
||||
failures: 10,
|
||||
success_rate: 0.9,
|
||||
// 3h30m before the fixture's now, far from a unit boundary.
|
||||
last_failure_at: NOW_S - 12_600,
|
||||
last_failure_error: "timeout",
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "udp://9.9.9.9:53",
|
||||
enabled: true,
|
||||
available: true,
|
||||
period: {
|
||||
attempts: 100,
|
||||
successes: 100,
|
||||
failures: 0,
|
||||
success_rate: 1,
|
||||
last_failure_at: null,
|
||||
last_failure_error: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/upstream/health?period=1h": {
|
||||
period: "1h",
|
||||
since: NOW_S - 3600,
|
||||
until: NOW_S,
|
||||
available: 1,
|
||||
total: 1,
|
||||
complete: true,
|
||||
upstreams: [
|
||||
{
|
||||
url: "https://dns.example/dns-query",
|
||||
enabled: true,
|
||||
available: true,
|
||||
period: {
|
||||
attempts: 7,
|
||||
successes: 6,
|
||||
failures: 1,
|
||||
success_rate: 6 / 7,
|
||||
last_failure_at: NOW_S - 300,
|
||||
last_failure_error: "timeout",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
|
||||
// Endpoints forced to fail with a 4xx, which the query client does not retry.
|
||||
let failing: Set<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
failing = new Set();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (failing.has(url)) {
|
||||
return new Response(JSON.stringify({ error: "upstream health unavailable" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const payload = RESPONSES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderDashboard() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
test("dashboard renders stats, chart, disk card, upstream table and health banners", async () => {
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
expect(screen.getByText("1,000")).toBeTruthy();
|
||||
expect(screen.getByText("250")).toBeTruthy();
|
||||
expect(screen.getByText("25.0%")).toBeTruthy();
|
||||
expect(screen.getByText("7")).toBeTruthy();
|
||||
expect(screen.getByText("2.3 ms")).toBeTruthy();
|
||||
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
|
||||
|
||||
expect(screen.getByText("Storage now")).toBeTruthy();
|
||||
expect(screen.getByText("warn")).toBeTruthy();
|
||||
expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("12.0 MiB")).toBeTruthy();
|
||||
expect(screen.getByText("2.0 KiB")).toBeTruthy();
|
||||
|
||||
const alerts = screen.getAllByRole("alert");
|
||||
expect(alerts.some((alert) => /disk space low/i.test(alert.textContent ?? ""))).toBe(true);
|
||||
expect(alerts.some((alert) => /5 queries dropped/i.test(alert.textContent ?? ""))).toBe(true);
|
||||
|
||||
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
|
||||
expect(screen.getByText("90.0%")).toBeTruthy();
|
||||
expect(screen.getByText("100.0%")).toBeTruthy();
|
||||
expect(screen.getByText("timeout · 3h ago")).toBeTruthy();
|
||||
expect(screen.getByText("1/2 available")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("live state is labeled on its own card, not by a section that disowns the picker", async () => {
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
expect(screen.getByText("Storage now")).toBeTruthy();
|
||||
expect(screen.queryByRole("region", { name: "Right now" })).toBeNull();
|
||||
expect(screen.queryByText("Right now")).toBeNull();
|
||||
expect(screen.queryByText("Snapshot state; the period above does not apply.")).toBeNull();
|
||||
});
|
||||
|
||||
test("the period picker rescopes the upstream table", async () => {
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
await screen.findByText("90.0%");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
||||
|
||||
await screen.findByText("85.7%");
|
||||
expect(screen.getByRole("columnheader", { name: "Selected period · 1h" })).toBeTruthy();
|
||||
expect(screen.queryByText("90.0%")).toBeNull();
|
||||
});
|
||||
|
||||
test("period picker refetches stats and shows the empty chart state", async () => {
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
||||
|
||||
await screen.findByText("12");
|
||||
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
|
||||
await screen.findByText("No queries in this period.");
|
||||
expect(screen.getByText("—", { selector: "span" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("one failing endpoint degrades its own widget on cold navigation", async () => {
|
||||
failing.add("/api/upstream/health?period=24h");
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
// The page renders; only the upstream widget carries the error.
|
||||
await screen.findByText("upstream health unavailable");
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
expect(screen.queryByText("Request failed (400)")).toBeNull();
|
||||
|
||||
expect(screen.getByText("1,000")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
expect(screen.getByText("Storage now")).toBeTruthy();
|
||||
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
|
||||
});
|
||||
@@ -1,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 storage card from `lg`, one column below. */
|
||||
panelGrid: {
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 1024px)": "2fr 1fr",
|
||||
},
|
||||
},
|
||||
panel: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
panelHeading: {
|
||||
marginBottom: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
});
|
||||
|
||||
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
||||
return (
|
||||
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
|
||||
{PERIODS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={option === period}
|
||||
onClick={() => onChange(option)}
|
||||
{...stylex.props(
|
||||
styles.period,
|
||||
option === period ? styles.periodSelected : styles.periodIdle,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Skeleton({ height }: { height: number }) {
|
||||
return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [period, setPeriod] = useState<Period>("24h");
|
||||
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
|
||||
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
|
||||
const health = useQuery(healthQuery());
|
||||
const upstreamHealth = useQuery({ ...upstreamHealthQuery(period), placeholderData: keepPreviousData });
|
||||
|
||||
return (
|
||||
<section {...stylex.props(styles.page)}>
|
||||
<div {...stylex.props(styles.titleRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Dashboard</h1>
|
||||
<PeriodPicker period={period} onChange={setPeriod} />
|
||||
</div>
|
||||
|
||||
{health.data !== undefined && <HealthBanners health={health.data} />}
|
||||
|
||||
{stats.isError ? (
|
||||
<InlineError error={stats.error} onRetry={() => void stats.refetch()} />
|
||||
) : stats.data === undefined ? (
|
||||
<Skeleton height={76} />
|
||||
) : (
|
||||
<StatCards stats={stats.data} />
|
||||
)}
|
||||
|
||||
<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,99 +0,0 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { Health } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
card: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
badge: {
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
/**
|
||||
* The badge fills are their own three-step scale, not the `danger`/`warn`
|
||||
* banner tokens: they read as a tinted chip against a raised card, where a
|
||||
* banner fill would be too heavy.
|
||||
*/
|
||||
ok: {
|
||||
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
|
||||
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
|
||||
},
|
||||
warn: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
|
||||
},
|
||||
critical: {
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
|
||||
},
|
||||
list: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
row: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
term: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
function stateStyle(state: Health["disk"]["state"]) {
|
||||
if (state === "critical") return styles.critical;
|
||||
return state === "warn" ? styles.warn : styles.ok;
|
||||
}
|
||||
|
||||
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
|
||||
return (
|
||||
<section {...stylex.props(styles.card)}>
|
||||
{/* Live state, unlike the ranged widgets around it; the title says so
|
||||
rather than a section rule the picker would have to disown. */}
|
||||
<h2 {...stylex.props(styles.heading)}>
|
||||
Storage now
|
||||
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
|
||||
</h2>
|
||||
<dl {...stylex.props(styles.list)}>
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<dt {...stylex.props(styles.term)}>Free</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
|
||||
</div>
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<dt {...stylex.props(styles.term)}>Database</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
|
||||
</div>
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<dt {...stylex.props(styles.term)}>Logs</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { Health } from "@/lib/types";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
stack: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
banner: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
warn: {
|
||||
borderColor: colors.warnBorder,
|
||||
backgroundColor: colors.warnSurface,
|
||||
color: colors.warnText,
|
||||
},
|
||||
critical: {
|
||||
borderColor: colors.dangerBorder,
|
||||
backgroundColor: colors.dangerSurface,
|
||||
color: colors.dangerText,
|
||||
},
|
||||
});
|
||||
|
||||
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
|
||||
return (
|
||||
<p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HealthBanners({ health }: { health: Health }) {
|
||||
const banners: React.ReactNode[] = [];
|
||||
if (health.disk.state !== "ok") {
|
||||
banners.push(
|
||||
<Banner key="disk" tone={health.disk.state === "critical" ? "critical" : "warn"}>
|
||||
{health.disk.state === "critical"
|
||||
? `Disk critically low: ${formatBytes(health.disk.free_bytes)} free. Blocklist updates and log flushes are stopped.`
|
||||
: `Disk space low: ${formatBytes(health.disk.free_bytes)} free.`}
|
||||
</Banner>,
|
||||
);
|
||||
}
|
||||
if (health.writer_failed) {
|
||||
banners.push(
|
||||
<Banner key="writer" tone="critical">
|
||||
Query log writer failed; new queries are not being persisted.
|
||||
</Banner>,
|
||||
);
|
||||
}
|
||||
if (health.queries_dropped > 0) {
|
||||
banners.push(
|
||||
<Banner key="dropped" tone="warn">
|
||||
{health.queries_dropped.toLocaleString()} queries dropped from the log buffer.
|
||||
</Banner>,
|
||||
);
|
||||
}
|
||||
if (banners.length === 0) return null;
|
||||
return <div {...stylex.props(styles.stack)}>{banners}</div>;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatMicros } from "@/lib/format";
|
||||
import type { StatsTotals } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
const styles = stylex.create({
|
||||
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
|
||||
grid: {
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
|
||||
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
card: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
label: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
value: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
detail: {
|
||||
marginLeft: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
function percentOf(part: number, total: number): string | null {
|
||||
if (total === 0) return null;
|
||||
return `${((part / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
|
||||
return (
|
||||
<div {...stylex.props(styles.card)}>
|
||||
<dt {...stylex.props(styles.label)}>{label}</dt>
|
||||
<dd>
|
||||
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
|
||||
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatCards({ stats }: { stats: StatsTotals }) {
|
||||
return (
|
||||
<dl {...stylex.props(styles.grid)}>
|
||||
<Card label="Queries" value={numberFormat.format(stats.queries)} />
|
||||
<Card
|
||||
label="Blocked"
|
||||
value={numberFormat.format(stats.blocked)}
|
||||
detail={percentOf(stats.blocked, stats.queries)}
|
||||
/>
|
||||
<Card
|
||||
label="Cached"
|
||||
value={numberFormat.format(stats.cached)}
|
||||
detail={percentOf(stats.cached, stats.queries)}
|
||||
/>
|
||||
<Card label="Clients" value={numberFormat.format(stats.clients)} />
|
||||
<Card
|
||||
label="Avg response"
|
||||
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
|
||||
/>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
|
||||
import UpstreamHealthTable from "./UpstreamHealthTable";
|
||||
|
||||
const NOW_S = 1_700_000_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW_S * 1000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const ZERO: UpstreamPeriodStats = {
|
||||
attempts: 0,
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
success_rate: null,
|
||||
last_failure_at: null,
|
||||
last_failure_error: null,
|
||||
};
|
||||
|
||||
function period(overrides: Partial<UpstreamPeriodStats> = {}): UpstreamPeriodStats {
|
||||
return {
|
||||
attempts: 100,
|
||||
successes: 90,
|
||||
failures: 10,
|
||||
success_rate: 0.9,
|
||||
// 3h30m ago, far from a unit boundary.
|
||||
last_failure_at: NOW_S - 12_600,
|
||||
last_failure_error: "Timeout",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(overrides: Partial<UpstreamHealthEntry> = {}): UpstreamHealthEntry {
|
||||
return {
|
||||
url: "https://dns.example/dns-query",
|
||||
enabled: true,
|
||||
available: true,
|
||||
period: period(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTable(upstreams: UpstreamHealthEntry[], overrides: Partial<UpstreamHealth> = {}) {
|
||||
const health: UpstreamHealth = {
|
||||
period: "24h",
|
||||
since: NOW_S - 86_400,
|
||||
until: NOW_S,
|
||||
available: upstreams.filter((upstream) => upstream.available).length,
|
||||
total: upstreams.length,
|
||||
complete: true,
|
||||
upstreams,
|
||||
...overrides,
|
||||
};
|
||||
render(<UpstreamHealthTable health={health} />);
|
||||
}
|
||||
|
||||
function rowOf(url: string): HTMLElement {
|
||||
const cell = screen.getByText(url);
|
||||
const row = cell.closest("tr");
|
||||
if (row === null) throw new Error(`no row for ${url}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
test("the ranged columns sit under a header naming the selected period", () => {
|
||||
renderTable([entry()]);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "Selected period · 24h" })).toBeTruthy();
|
||||
for (const name of ["Upstream", "Status now", "Attempts", "Failures", "Success rate", "Last failure"]) {
|
||||
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
|
||||
}
|
||||
|
||||
// The unranged yes/no pair the ranged table replaced.
|
||||
expect(screen.queryByRole("columnheader", { name: "Enabled" })).toBeNull();
|
||||
expect(screen.queryByRole("columnheader", { name: "Available" })).toBeNull();
|
||||
});
|
||||
|
||||
test("status now is one word from live state, not from the window", () => {
|
||||
renderTable([
|
||||
entry({ url: "https://a.example/dns-query" }),
|
||||
entry({ url: "https://b.example/dns-query", available: false }),
|
||||
entry({ url: "https://c.example/dns-query", enabled: false, available: false }),
|
||||
]);
|
||||
|
||||
expect(within(rowOf("https://a.example/dns-query")).getByText("Available")).toBeTruthy();
|
||||
expect(within(rowOf("https://b.example/dns-query")).getByText("Backing off")).toBeTruthy();
|
||||
expect(within(rowOf("https://c.example/dns-query")).getByText("Disabled")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("last failure pairs the error name with its age, em-dash when the window holds none", () => {
|
||||
renderTable([
|
||||
entry({ url: "https://a.example/dns-query" }),
|
||||
entry({
|
||||
url: "https://b.example/dns-query",
|
||||
period: period({ last_failure_at: null, last_failure_error: null }),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(within(rowOf("https://a.example/dns-query")).getByText("Timeout · 3h ago")).toBeTruthy();
|
||||
expect(within(rowOf("https://b.example/dns-query")).getByText("—")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a window with no attempts renders em-dashes and never a perfect rate", () => {
|
||||
renderTable([entry({ period: ZERO })]);
|
||||
|
||||
const cells = within(rowOf("https://dns.example/dns-query")).getAllByRole("cell");
|
||||
expect(cells.map((cell) => cell.textContent)).toEqual([
|
||||
"https://dns.example/dns-query",
|
||||
"Available",
|
||||
"0",
|
||||
"0",
|
||||
"—",
|
||||
"—",
|
||||
]);
|
||||
expect(screen.queryByText("100.0%")).toBeNull();
|
||||
expect(screen.queryByText("0.0%")).toBeNull();
|
||||
});
|
||||
|
||||
test("the card says so when every upstream was idle in the window", () => {
|
||||
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry({ period: ZERO })]);
|
||||
|
||||
expect(screen.getByText("No upstream attempts in this period.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("one upstream with attempts keeps the idle message away", () => {
|
||||
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry()]);
|
||||
|
||||
expect(screen.queryByText("No upstream attempts in this period.")).toBeNull();
|
||||
});
|
||||
|
||||
test("an incomplete window carries a note; a complete one claims nothing", () => {
|
||||
renderTable([entry()], { complete: false });
|
||||
expect(screen.getByText(/history incomplete/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a complete window shows no completeness text at all", () => {
|
||||
renderTable([entry()], { complete: true });
|
||||
|
||||
expect(screen.queryByText(/history incomplete/i)).toBeNull();
|
||||
expect(screen.queryByText(/complete/i)).toBeNull();
|
||||
});
|
||||
|
||||
test("an empty pool says so instead of drawing a table", () => {
|
||||
renderTable([]);
|
||||
|
||||
expect(screen.getByText("No upstreams configured.")).toBeTruthy();
|
||||
expect(screen.queryByRole("table")).toBeNull();
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatAge } from "@/lib/format";
|
||||
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
const styles = stylex.create({
|
||||
card: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
heading: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
paddingInline: "1rem",
|
||||
paddingTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
count: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 400,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
empty: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
note: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
overflowX: "auto",
|
||||
},
|
||||
table: {
|
||||
marginTop: "0.5rem",
|
||||
width: "100%",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/**
|
||||
* The two live columns are left outside the span: everything under it answers
|
||||
* for the selected window, and nothing else on this card does.
|
||||
*/
|
||||
groupRow: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
groupHead: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
paddingInline: "1rem",
|
||||
paddingBottom: "0.25rem",
|
||||
textAlign: "center",
|
||||
fontWeight: 500,
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
textAlign: "left",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
th: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
thRight: {
|
||||
textAlign: "right",
|
||||
},
|
||||
/** No hairline under the last row: the card border already closes the table. */
|
||||
row: {
|
||||
borderBottomWidth: { default: 1, ":last-child": 0 },
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
cellRight: {
|
||||
textAlign: "right",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
bad: {
|
||||
color: colors.danger,
|
||||
},
|
||||
});
|
||||
|
||||
/** Live pool state in one word. Configuration first: a disabled upstream is not backing off. */
|
||||
function statusNow(upstream: UpstreamHealthEntry): "Available" | "Backing off" | "Disabled" {
|
||||
if (!upstream.enabled) return "Disabled";
|
||||
return upstream.available ? "Available" : "Backing off";
|
||||
}
|
||||
|
||||
/**
|
||||
* `success_rate` is null exactly when the window holds no attempt, and that must
|
||||
* not read as perfect reliability — hence the em-dash rather than `100.0%`.
|
||||
*/
|
||||
function successRate(period: UpstreamPeriodStats): string {
|
||||
return period.success_rate === null ? "—" : `${(period.success_rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The age is formatted once, when the row renders; nothing here ticks. It is
|
||||
* measured against the browser's clock rather than the response's `until`, so a
|
||||
* cached response ages visibly instead of freezing at the moment it was served.
|
||||
*/
|
||||
function lastFailure(period: UpstreamPeriodStats, nowSeconds: number): string {
|
||||
if (period.last_failure_at === null) return "—";
|
||||
const age = formatAge(Math.max(0, nowSeconds - period.last_failure_at));
|
||||
const error = period.last_failure_error;
|
||||
return error === null || error === "" ? age : `${error} · ${age}`;
|
||||
}
|
||||
|
||||
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const idle = health.upstreams.length > 0 && health.upstreams.every(({ period }) => period.attempts === 0);
|
||||
|
||||
return (
|
||||
<section {...stylex.props(styles.card)}>
|
||||
<h2 {...stylex.props(styles.heading)}>
|
||||
Upstreams
|
||||
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
||||
{health.available}/{health.total} available
|
||||
</span>
|
||||
</h2>
|
||||
{health.upstreams.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
|
||||
) : (
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.groupRow)}>
|
||||
<td colSpan={2} />
|
||||
<th scope="colgroup" colSpan={4} {...stylex.props(styles.groupHead)}>
|
||||
Selected period · {health.period}
|
||||
</th>
|
||||
</tr>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
Upstream
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
Status now
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||
Attempts
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||
Failures
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||
Success rate
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
Last failure
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{health.upstreams.map((upstream) => {
|
||||
const status = statusNow(upstream);
|
||||
return (
|
||||
<tr key={upstream.url} {...stylex.props(styles.row)}>
|
||||
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>
|
||||
{upstream.url}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<span
|
||||
{...stylex.props(
|
||||
status === "Backing off" && styles.bad,
|
||||
status === "Disabled" && styles.muted,
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{numberFormat.format(upstream.period.attempts)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{numberFormat.format(upstream.period.failures)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{successRate(upstream.period)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
|
||||
{lastFailure(upstream.period, nowSeconds)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{idle && <p {...stylex.props(styles.note)}>No upstream attempts in this period.</p>}
|
||||
{!health.complete && (
|
||||
<p {...stylex.props(styles.note)}>
|
||||
History incomplete: outcomes were dropped in this window, so these counts are a lower bound.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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 Blocklists" }).getAttribute("href")).toBe("/blocklists");
|
||||
});
|
||||
|
||||
test("a resolved episode states how long it lasted, not how long it has run", async () => {
|
||||
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
|
||||
renderDetail(7);
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
expect(screen.getByText("Resolved after 1h")).toBeTruthy();
|
||||
expect(screen.queryByText("Not yet — still failing")).toBeNull();
|
||||
});
|
||||
|
||||
test("an open episode offers no purge", async () => {
|
||||
responses["/api/diagnostics/42"] = event();
|
||||
renderDetail(42);
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
expect(screen.queryByRole("button", { name: "Purge" })).toBeNull();
|
||||
});
|
||||
|
||||
test("purging a resolved episode asks first, then returns to the list", async () => {
|
||||
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
|
||||
responses["DELETE /api/diagnostics/7"] = NO_CONTENT;
|
||||
responses["/api/diagnostics?state=active"] = { events: [], next_before: null, active: { warnings: 0, errors: 0 } };
|
||||
responses["/api/diagnostics?state=resolved"] = {
|
||||
events: [],
|
||||
next_before: null,
|
||||
active: { warnings: 0, errors: 0 },
|
||||
};
|
||||
const router = renderDetail(7);
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good.");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(requested).not.toContain("DELETE /api/diagnostics/7");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
|
||||
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
|
||||
|
||||
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/7"));
|
||||
// The row it was showing no longer exists, so the page it navigates to is
|
||||
// the list rather than a 404 of its own.
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/diagnostics"));
|
||||
});
|
||||
|
||||
test("a refused purge stays on the event and shows why", async () => {
|
||||
responses["/api/diagnostics/7"] = event({ id: 7, resolved_at: NOW_S - 3600 });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
|
||||
if (init?.method === "DELETE")
|
||||
return new Response(JSON.stringify({ error: "the event is still active" }), {
|
||||
status: 409,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
return new Response(JSON.stringify(responses[String(input)] ?? {}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
const router = renderDetail(7, { retry: false });
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Purge" }));
|
||||
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("the event is still active");
|
||||
expect(router.state.location.pathname).toBe("/diagnostics/7");
|
||||
});
|
||||
|
||||
test("every code renders its own title, impact and remediation", async () => {
|
||||
for (const [index, code] of DIAGNOSTIC_CODES.entries()) {
|
||||
const id = 100 + index;
|
||||
responses[`/api/diagnostics/${id}`] = event({ id, code, component: code.slice(0, code.indexOf(".")) });
|
||||
renderDetail(id);
|
||||
|
||||
const copy = EVENT_COPY[code];
|
||||
await screen.findByRole("heading", { name: copy.title });
|
||||
expect(screen.getByText(copy.impact), code).toBeTruthy();
|
||||
expect(screen.getByText(copy.remediation), code).toBeTruthy();
|
||||
screen.getByText(code);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an event retention has removed shows the server's message, not an empty page", async () => {
|
||||
renderDetail(999);
|
||||
await screen.findByText("no such event");
|
||||
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an unavailable store reports the failure instead of loading forever", async () => {
|
||||
responses["/api/diagnostics/42"] = event();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input).startsWith("/api/diagnostics/")
|
||||
? new Response(JSON.stringify({ error: "store unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
: new Response(JSON.stringify(responses[String(input)] ?? {}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderDetail(42, { retry: false });
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("The server is starting or degraded.");
|
||||
expect(screen.queryByText("Loading event…")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { formatDuration, formatTime } from "@/lib/format";
|
||||
import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import SeverityBadge from "./SeverityBadge";
|
||||
import { componentLabel, copyFor } from "./eventCopy";
|
||||
|
||||
const styles = stylex.create({
|
||||
back: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
headingRow: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
purgeAction: {
|
||||
marginInlineStart: "auto",
|
||||
},
|
||||
subject: {
|
||||
marginTop: "0.25rem",
|
||||
color: colors.textSecondary,
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
panel: {
|
||||
marginTop: "1rem",
|
||||
maxWidth: "48rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: "1rem",
|
||||
},
|
||||
facts: {
|
||||
display: "grid",
|
||||
gap: "0.5rem 1rem",
|
||||
gridTemplateColumns: {
|
||||
default: "auto",
|
||||
"@media (min-width: 640px)": "max-content 1fr",
|
||||
},
|
||||
margin: 0,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
term: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
value: {
|
||||
margin: 0,
|
||||
},
|
||||
sectionHeading: {
|
||||
marginTop: "1.5rem",
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
prose: {
|
||||
marginTop: "0.5rem",
|
||||
maxWidth: "48rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.5rem",
|
||||
},
|
||||
detail: {
|
||||
marginTop: "0.5rem",
|
||||
maxWidth: "48rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
padding: "0.75rem",
|
||||
fontSize: "0.8125rem",
|
||||
lineHeight: "1.25rem",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
links: {
|
||||
marginTop: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
link: {
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
loading: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export default function DiagnosticDetailPage() {
|
||||
const { id } = useParams({ from: "/shell/diagnostics/$id" });
|
||||
const eventId = Number(id);
|
||||
const { data, error, isPending, refetch } = useQuery(diagnosticQuery(eventId));
|
||||
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const purge = useMutation(diagnosticPurgeMutation(queryClient));
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
function confirmPurge() {
|
||||
setConfirming(false);
|
||||
// The row this page is about is gone, so staying here would show the
|
||||
// 404 the purge itself caused.
|
||||
purge.mutate(eventId, { onSuccess: () => void navigate({ to: "/diagnostics" }) });
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
|
||||
Loading event…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<section>
|
||||
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← All diagnostics
|
||||
</Link>
|
||||
<InlineError error={error} onRetry={() => void refetch()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const copy = copyFor(data.code);
|
||||
const resolvedAt = data.resolved_at;
|
||||
const span = (resolvedAt ?? Math.floor(Date.now() / 1000)) - data.first_seen;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← All diagnostics
|
||||
</Link>
|
||||
<div {...stylex.props(styles.headingRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>{copy.title}</h1>
|
||||
<SeverityBadge severity={data.severity} />
|
||||
{/* Only history can be purged: an open episode is the current state of the box. */}
|
||||
{resolvedAt !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={purge.isPending}
|
||||
{...stylex.props(styles.purgeAction, shared.dangerLinkButton, shared.focusRing)}
|
||||
>
|
||||
Purge
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p {...stylex.props(styles.subject)}>{data.subject}</p>
|
||||
<InlineError error={purge.error} />
|
||||
|
||||
<div {...stylex.props(styles.panel)}>
|
||||
<dl {...stylex.props(styles.facts)}>
|
||||
<dt {...stylex.props(styles.term)}>State</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
{resolvedAt === null
|
||||
? `Active for ${formatDuration(span)}`
|
||||
: `Resolved after ${formatDuration(span)}`}
|
||||
</dd>
|
||||
<dt {...stylex.props(styles.term)}>First seen</dt>
|
||||
<dd {...stylex.props(styles.value)}>{formatTime(data.first_seen)}</dd>
|
||||
<dt {...stylex.props(styles.term)}>Last seen</dt>
|
||||
<dd {...stylex.props(styles.value)}>{formatTime(data.last_seen)}</dd>
|
||||
<dt {...stylex.props(styles.term)}>Occurrences</dt>
|
||||
<dd {...stylex.props(styles.value, shared.tabularNums)}>{data.occurrences}</dd>
|
||||
<dt {...stylex.props(styles.term)}>Resolved</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
{data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)}
|
||||
</dd>
|
||||
<dt {...stylex.props(styles.term)}>Component</dt>
|
||||
<dd {...stylex.props(styles.value)}>{componentLabel(data.component)}</dd>
|
||||
<dt {...stylex.props(styles.term)}>Code</dt>
|
||||
<dd {...stylex.props(styles.value, shared.mono)}>{data.code}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>Impact</h2>
|
||||
<p {...stylex.props(styles.prose)}>{copy.impact}</p>
|
||||
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>What to do</h2>
|
||||
<p {...stylex.props(styles.prose)}>{copy.remediation}</p>
|
||||
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>Last error</h2>
|
||||
{data.detail === "" ? (
|
||||
<p {...stylex.props(styles.prose)}>The server recorded no error text for this event.</p>
|
||||
) : (
|
||||
<pre {...stylex.props(styles.detail, shared.mono)}>{data.detail}</pre>
|
||||
)}
|
||||
|
||||
{copy.link !== undefined && (
|
||||
<p {...stylex.props(styles.links)}>
|
||||
<Link to={copy.link.to} {...stylex.props(styles.link, shared.focusRing)}>
|
||||
Go to {copy.link.label}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
title="Purge event"
|
||||
message="Purge this resolved event? Its history is gone for good."
|
||||
confirmLabel="Purge"
|
||||
onConfirm={confirmPurge}
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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,195 @@
|
||||
/**
|
||||
* 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 Blocklists, upstreams to Upstreams", 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: "Blocklists" }).getAttribute("href")).toBe(
|
||||
"/blocklists",
|
||||
);
|
||||
expect(within(fact("Upstreams")).getByRole("link", { name: "Upstreams" }).getAttribute("href")).toBe("/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();
|
||||
expect(screen.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,178 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link to={link.to} {...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,161 @@
|
||||
/**
|
||||
* What each event code means to the operator, in three fixed fields: what the
|
||||
* episode is (`title`), what it costs while it stays open (`impact`), and what
|
||||
* to do about it (`remediation`). The server sends a code and an error string;
|
||||
* every word of explanation the page shows comes from here.
|
||||
*
|
||||
* The record is exhaustive over `DiagnosticCode` by type, and a test walks
|
||||
* `DIAGNOSTIC_CODES` to prove it at runtime too. A sixteenth code added to the
|
||||
* enum fails `tsc` here before it can reach the page as a bare dotted string.
|
||||
*
|
||||
* `link` points at the configuration surface that governs the failure. Those
|
||||
* are today's routes; the navigation restructure re-points them.
|
||||
*/
|
||||
|
||||
import { DIAGNOSTIC_CODES, type DiagnosticCode } from "@/lib/types";
|
||||
|
||||
/** The literal paths keep `link.to` assignable to a typed router `Link`. */
|
||||
export type CopyLinkPath = "/settings" | "/blocklists" | "/upstreams" | "/clients";
|
||||
|
||||
export interface EventCopy {
|
||||
title: string;
|
||||
impact: string;
|
||||
remediation: string;
|
||||
link?: { to: CopyLinkPath; label: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy for a code, with a floor under it. `tsc` proves the record covers
|
||||
* the union, but a server one release ahead can send a code this build has
|
||||
* never heard of; showing the raw code beats rendering "undefined".
|
||||
*/
|
||||
export function copyFor(code: DiagnosticCode): EventCopy {
|
||||
return (
|
||||
EVENT_COPY[code] ?? {
|
||||
title: code,
|
||||
impact: "This build has no description for this event code.",
|
||||
remediation: "The error detail below is the whole of what the server reported.",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const SETTINGS = { to: "/settings", label: "Settings" } as const;
|
||||
const BLOCKLISTS = { to: "/blocklists", label: "Blocklists" } as const;
|
||||
const UPSTREAMS = { to: "/upstreams", label: "Upstreams" } as const;
|
||||
const CLIENTS = { to: "/clients", label: "Clients" } as const;
|
||||
|
||||
/**
|
||||
* The component filter's options, derived from the codes rather than listed
|
||||
* again: the server matches `component` against the part of `code` before the
|
||||
* dot, so any list written by hand here could drift from the enum.
|
||||
*/
|
||||
export const DIAGNOSTIC_COMPONENTS: readonly string[] = [
|
||||
...new Set(DIAGNOSTIC_CODES.map((code) => code.slice(0, code.indexOf(".")))),
|
||||
];
|
||||
|
||||
/** `query_log` → "Query log". Display only; the filter sends the raw component. */
|
||||
export function componentLabel(component: string): string {
|
||||
const spaced = component.replaceAll("_", " ");
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
export const EVENT_COPY: Record<DiagnosticCode, EventCopy> = {
|
||||
"disk.space": {
|
||||
title: "Disk space low",
|
||||
impact: "Below the critical threshold nxdns stops blocklist updates and query log flushes to protect the disk.",
|
||||
remediation: "Free space on the data volume, or lower the retention window so the query log holds fewer days.",
|
||||
link: SETTINGS,
|
||||
},
|
||||
"disk.probe": {
|
||||
title: "Disk usage probe failed",
|
||||
impact: "Free space is unknown, so the low-disk guard cannot act until a probe succeeds.",
|
||||
remediation: "Check that the data and log directories exist and that the service user can read them.",
|
||||
link: SETTINGS,
|
||||
},
|
||||
"blocklist.refresh": {
|
||||
title: "Blocklist source failed to update",
|
||||
impact: "The source keeps serving its last good snapshot, so blocking continues but the list ages.",
|
||||
remediation: "Check the source url and the machine's internet access, then update the lists again.",
|
||||
link: BLOCKLISTS,
|
||||
},
|
||||
"blocklist.snapshot": {
|
||||
title: "Filter snapshot failed to publish",
|
||||
impact: "The resolver keeps the snapshot it already holds; blocklist edits do not take effect until one publishes.",
|
||||
remediation: "Check free disk space and the data directory's permissions, then update the lists again.",
|
||||
link: BLOCKLISTS,
|
||||
},
|
||||
"blocklist.storage": {
|
||||
title: "Blocklist storage operation failed",
|
||||
impact: "Cached list files or their database rows are out of step; a later pass can redownload what is missing.",
|
||||
remediation: "Check free disk space and the data directory's permissions.",
|
||||
link: BLOCKLISTS,
|
||||
},
|
||||
"certificate.reload": {
|
||||
title: "TLS certificate reload failed",
|
||||
impact: "The endpoint keeps serving the certificate it already loaded, which expires on its own schedule.",
|
||||
remediation:
|
||||
"Check the certificate and key paths, and that renewal writes both files the service user can read.",
|
||||
link: SETTINGS,
|
||||
},
|
||||
"query_log.write": {
|
||||
title: "Query log write failed",
|
||||
impact: "Queries are resolved and answered as usual, but they are not being recorded.",
|
||||
remediation: "Check free disk space and the log database's permissions, then restart nxdns.",
|
||||
link: SETTINGS,
|
||||
},
|
||||
"query_log.maintenance": {
|
||||
title: "Query log maintenance failed",
|
||||
impact: "Old rows are not being trimmed, so the log database grows past its retention window.",
|
||||
remediation: "Check free disk space; the next maintenance pass retries on its own.",
|
||||
link: SETTINGS,
|
||||
},
|
||||
"query_log.recreated": {
|
||||
title: "Query log recreated",
|
||||
impact: "The old log database was 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: "/blocklists", label: "Blocklists" });
|
||||
});
|
||||
|
||||
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: "/upstreams", 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: "/blocklists" | "/upstreams"; 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: "/blocklists", label: "Blocklists" },
|
||||
};
|
||||
}
|
||||
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: "/upstreams", 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,194 +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();
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with no tooltip standing in.
|
||||
const nameless = screen.getByText("192.0.2.12");
|
||||
expect(nameless.getAttribute("title")).toBeNull();
|
||||
const stranger = screen.getByText("192.0.2.99");
|
||||
expect(stranger.getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("stranger.example").closest("tr")?.textContent).toContain("192.0.2.99");
|
||||
});
|
||||
|
||||
test("rows stream in as bare IPs while the client list is still loading", async () => {
|
||||
let releaseClients: () => void = () => {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
(input: RequestInfo | URL) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
if (String(input) !== "/api/clients") {
|
||||
resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return;
|
||||
}
|
||||
releaseClients = () =>
|
||||
resolve(
|
||||
new Response(JSON.stringify({ clients: CLIENTS }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const sources = renderPage();
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" })));
|
||||
|
||||
expect(screen.getByText("192.0.2.10")).toBeTruthy();
|
||||
expect(screen.queryByText("Kitchen Pi")).toBeNull();
|
||||
|
||||
releaseClients();
|
||||
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("repeated connection failures show the viewer-cap state with a retry button", () => {
|
||||
const sources = renderPage();
|
||||
act(() => {
|
||||
sources[0]!.emit("error");
|
||||
sources[0]!.emit("error");
|
||||
sources[0]!.emit("error");
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||
});
|
||||
@@ -1,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]);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
|
||||
|
||||
/** A live stream row; `key` is a client-side monotonic counter (SSE frames carry no id). */
|
||||
export interface LiveRow extends LiveQueryEvent {
|
||||
key: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// `since` on GET /api/queries is inclusive, so the re-sync fetch returns the
|
||||
// last-seen row(s) again; live rows have no id, so identity is this tuple.
|
||||
function signature(row: LiveQueryEvent): string {
|
||||
return `${row.ts}|${row.domain}|${row.client_ip}|${row.qtype ?? -1}|${row.blocked}|${row.upstream}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge rows fetched for a reconnect gap (newest-first, from GET /api/queries)
|
||||
* into the buffer. Rows already present are skipped; `missed` counts what was
|
||||
* actually added. 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 seen = new Set(rows.map(signature));
|
||||
const added: LiveRow[] = [];
|
||||
for (const row of fetched) {
|
||||
const event: LiveQueryEvent = {
|
||||
ts: row.ts,
|
||||
domain: row.domain,
|
||||
client_ip: row.client_ip,
|
||||
qtype: row.qtype,
|
||||
blocked: row.blocked,
|
||||
block_reason: row.block_reason,
|
||||
response_time_us: row.response_time_us,
|
||||
cache_hit: row.cache_hit,
|
||||
upstream: row.upstream,
|
||||
};
|
||||
if (seen.has(signature(event))) continue;
|
||||
added.push({ ...event, key: nextKey() });
|
||||
}
|
||||
if (added.length === 0) return { rows, missed: 0 };
|
||||
const merged = [...added, ...rows].sort((a, b) => b.ts - a.ts).slice(0, capacity);
|
||||
return { rows: merged, missed: added.length };
|
||||
}
|
||||
@@ -1,95 +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";
|
||||
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>;
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
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 === "/api/lookup?domain=ads.example&group_id=1") return json(BLOCKED);
|
||||
return json({ error: "not stubbed" }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/lookup"] }), 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: "Lookup" });
|
||||
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: "Look up" }));
|
||||
|
||||
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("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");
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Client activity over the same window as the query-volume chart: one stacked
|
||||
* series per named client, plus everything outside the top eight as "Other".
|
||||
*
|
||||
* The x-axis is the timeseries endpoint's own bucket alignment, so the two
|
||||
* charts stack directly above one another and a spike in one is at the same
|
||||
* horizontal position in the other. Colour keys on the client string, so a
|
||||
* client that changes rank between polls keeps its colour.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { StatsClients } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { layoutStacked } from "./chartLayout";
|
||||
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
|
||||
|
||||
const CHART_HEIGHT = 240;
|
||||
const FALLBACK_WIDTH = 640;
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
root: {
|
||||
position: "relative",
|
||||
},
|
||||
gridLine: {
|
||||
stroke: colors.border,
|
||||
},
|
||||
axisLine: {
|
||||
stroke: colors.borderStrong,
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
legend: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
columnGap: "1rem",
|
||||
rowGap: "0.25rem",
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
legendItem: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the colour the bars are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
setWidth(el.clientWidth);
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return [ref, width];
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
function formatTick(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
interface Series {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Kept beside the label so a renamed client is still identifiable by address. */
|
||||
address: string | null;
|
||||
color: string;
|
||||
buckets: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* "Other" last, so it sits at the top of every column rather than under a
|
||||
* client, and always present: the response always carries the series, and a
|
||||
* legend that dropped it on a quiet period would make the reader think the
|
||||
* chart's clients were all of them.
|
||||
*/
|
||||
function seriesOf(data: StatsClients, names: ClientNames): Series[] {
|
||||
const named = data.clients.map((client) => ({
|
||||
key: clientKey(client.client),
|
||||
// The name if the client is registered under one, the address otherwise —
|
||||
// the same precedence and the same lookup the query tables use. The colour
|
||||
// keys on the address regardless, so naming a client never repaints it.
|
||||
label: clientLabel(client.client, names)?.text ?? client.client,
|
||||
address: client.client,
|
||||
color: seriesColor(clientKey(client.client)),
|
||||
buckets: client.buckets,
|
||||
}));
|
||||
return [
|
||||
...named,
|
||||
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
|
||||
];
|
||||
}
|
||||
|
||||
export default function ClientChart({ data }: { data: StatsClients }) {
|
||||
const [containerRef, measuredWidth] = useContainerWidth();
|
||||
const names = useClientNames();
|
||||
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
|
||||
const series = seriesOf(data, names);
|
||||
const bucketCount = data.other.length;
|
||||
|
||||
if (bucketCount === 0) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = Array.from({ length: bucketCount }, (_, i) => ({
|
||||
ts: data.since + i * data.bucket_seconds,
|
||||
values: series.map((one) => one.buckets[i] ?? 0),
|
||||
}));
|
||||
if (columns.every((column) => column.values.every((value) => value === 0))) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const layout = layoutStacked(columns, width, CHART_HEIGHT);
|
||||
const baseline = layout.plot.y + layout.plot.height;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.root)}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Client activity over time, ${bucketCount} buckets, ${series.length} series`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
>
|
||||
{layout.yTicks.map((tick) => (
|
||||
<g key={tick.value}>
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
{...stylex.props(styles.gridLine)}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
>
|
||||
{compact.format(tick.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={baseline}
|
||||
y2={baseline}
|
||||
{...stylex.props(styles.axisLine)}
|
||||
/>
|
||||
{layout.xTicks.map((tick) => (
|
||||
<text
|
||||
key={tick.ts}
|
||||
x={tick.x}
|
||||
y={baseline + 14}
|
||||
textAnchor="middle"
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
>
|
||||
{formatTick(tick.ts, data.bucket_seconds)}
|
||||
</text>
|
||||
))}
|
||||
{layout.columns.map((column) => (
|
||||
<g key={column.ts}>
|
||||
{column.segments.map((rect, index) =>
|
||||
rect.height <= 0 ? null : (
|
||||
<rect
|
||||
key={series[index].key}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
fill={series[index].color}
|
||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<rect
|
||||
x={column.slot.x}
|
||||
y={column.slot.y}
|
||||
width={column.slot.width}
|
||||
height={column.slot.height}
|
||||
fill="transparent"
|
||||
>
|
||||
<title>
|
||||
{`${formatTime(column.ts)}: ${column.total} ${column.total === 1 ? "query" : "queries"}`}
|
||||
</title>
|
||||
</rect>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{series.map((one) => (
|
||||
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
|
||||
{one.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div {...stylex.props(shared.srOnly)}>
|
||||
<table>
|
||||
<caption>Queries per client per time bucket</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
{series.map((one) => (
|
||||
<th key={one.key} scope="col">
|
||||
{one.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((column) => (
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
{column.values.map((value, index) => (
|
||||
<td key={series[index].key}>{value}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* A breakdown as a ring, a legend and a table.
|
||||
*
|
||||
* The ring is decoration: it carries `aria-hidden` and `focusable="false"`,
|
||||
* because a non-focusable SVG is still in the accessibility tree and would
|
||||
* announce a pile of unlabelled paths. Everything the ring says is said again in
|
||||
* the legend — visibly, with the share and the count — and once more in a
|
||||
* visually hidden table, which is the surface a screen reader reads.
|
||||
*
|
||||
* Labels can collide: two rows can both be "Unknown", and one upstream name can
|
||||
* appear under two route kinds. Identity is therefore the caller's `key`, and an
|
||||
* entry that needs disambiguating carries `secondary` text saying which it is.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { layoutDonut, type DonutSlice } from "./donutLayout";
|
||||
|
||||
const SIZE = 180;
|
||||
const THICKNESS = 36;
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
/** The width at which the page puts the two donuts side by side, and the page's
|
||||
* own grid switches on the same query. StyleX will not take it from an import,
|
||||
* so it is written out in both modules and must be changed in both. */
|
||||
const TWO_COLUMN = "@media (min-width: 1280px)";
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: SIZE,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* Centred while the panels are stacked, left-anchored once they are side by
|
||||
* side. Stacked, the panel is as wide as the page and a ring pinned to the
|
||||
* left edge reads as a mistake; in a column it is one of a pair and lines up
|
||||
* with everything above it.
|
||||
*/
|
||||
body: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" },
|
||||
gap: "1.25rem",
|
||||
},
|
||||
ring: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
/**
|
||||
* Capped and left-anchored. Without the cap the row justifies across whatever
|
||||
* the panel is given — most of a metre of whitespace on a wide monitor — and a
|
||||
* label stops reading as belonging to the count opposite it.
|
||||
*/
|
||||
legend: {
|
||||
flex: 1,
|
||||
minWidth: "12rem",
|
||||
maxWidth: "24rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
legendItem: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
swatch: {
|
||||
flexShrink: 0,
|
||||
alignSelf: "center",
|
||||
display: "inline-block",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the colour the ring is drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
label: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
secondary: {
|
||||
marginLeft: "0.375rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
count: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
share: {
|
||||
minWidth: "3rem",
|
||||
textAlign: "right",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
function sharePercent(share: number): string {
|
||||
return `${(share * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export default function Donut({
|
||||
slices,
|
||||
caption,
|
||||
unit,
|
||||
}: {
|
||||
slices: DonutSlice[];
|
||||
/** Names the hidden table, so a screen reader knows which breakdown it is in. */
|
||||
caption: string;
|
||||
/** The column header for the counted thing, e.g. "Queries". */
|
||||
unit: string;
|
||||
}) {
|
||||
const layout = layoutDonut(slices, SIZE, THICKNESS);
|
||||
|
||||
if (layout.total === 0) {
|
||||
return <div {...stylex.props(styles.empty)}>No queries in this period.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.body)}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
width={SIZE}
|
||||
height={SIZE}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
{...stylex.props(styles.ring)}
|
||||
>
|
||||
{layout.arcs.map((arc) => (
|
||||
// The stroke is what keeps a shared hue from lying. Colour is a pure
|
||||
// function of identity, so two neighbouring slices can come out the
|
||||
// same; outlined in the panel's own colour they still read as two
|
||||
// shapes rather than merging into one. Attributes rather than a
|
||||
// class, as the client chart's segments are, so the separation is
|
||||
// visible to a test and not only to a stylesheet.
|
||||
<path
|
||||
key={arc.slice.key}
|
||||
d={arc.d}
|
||||
fill={arc.slice.color}
|
||||
fillRule="evenodd"
|
||||
stroke={colors.surfaceRaised}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{layout.arcs.map((arc) => (
|
||||
<li key={arc.slice.key} {...stylex.props(styles.legendItem)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchColor(arc.slice.color))}
|
||||
/>
|
||||
<span {...stylex.props(styles.label)}>
|
||||
{arc.slice.label}
|
||||
{arc.slice.secondary !== undefined && (
|
||||
<span {...stylex.props(styles.secondary)}>{arc.slice.secondary}</span>
|
||||
)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
||||
{numberFormat.format(arc.slice.value)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.share, shared.tabularNums)}>{sharePercent(arc.share)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div {...stylex.props(shared.srOnly)}>
|
||||
<table>
|
||||
<caption>{caption}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Entry</th>
|
||||
<th scope="col">{unit}</th>
|
||||
<th scope="col">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{layout.arcs.map((arc) => (
|
||||
<tr key={arc.slice.key}>
|
||||
<th scope="row">
|
||||
{arc.slice.secondary === undefined
|
||||
? arc.slice.label
|
||||
: `${arc.slice.label} (${arc.slice.secondary})`}
|
||||
</th>
|
||||
<td>{arc.slice.value}</td>
|
||||
<td>{sharePercent(arc.share)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Overview through the real router: Pi-hole's layout over our data.
|
||||
*
|
||||
* The behaviours of the superseded three-section build are accounted for here or
|
||||
* declared dead. The status rows and the issues list moved to the Diagnostics
|
||||
* page's health strip and its Active section; the Pause control moved to the
|
||||
* sidebar; the protection indicator is gone. What stays here is the period, the
|
||||
* window and the panels.
|
||||
*/
|
||||
|
||||
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 { clientKey, seriesColor } from "./seriesColors";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Health, StatsClients, StatsRoutes, StatsTimeseries, StatsTotals, StatsTypes } from "@/lib/types";
|
||||
|
||||
const SINCE = Date.UTC(2026, 0, 1, 0, 0) / 1000;
|
||||
const UNTIL = Date.UTC(2026, 0, 2, 0, 0) / 1000;
|
||||
const COVERAGE = { complete: true, available_since: SINCE };
|
||||
|
||||
const TOTALS: StatsTotals = {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: UNTIL,
|
||||
queries: 1000,
|
||||
blocked: 250,
|
||||
clients: 7,
|
||||
avg_response_time_us: 2345,
|
||||
coverage: COVERAGE,
|
||||
};
|
||||
|
||||
const SERIES: StatsTimeseries = {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: UNTIL,
|
||||
bucket_seconds: 1800,
|
||||
buckets: [
|
||||
{ ts: SINCE, queries: 60, blocked: 20, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 40, blocked: 0, cached: 0 },
|
||||
],
|
||||
coverage: COVERAGE,
|
||||
};
|
||||
|
||||
const CLIENTS: StatsClients = {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: UNTIL,
|
||||
bucket_seconds: 1800,
|
||||
clients: [
|
||||
{ client: "192.0.2.30", buckets: [40, 20] },
|
||||
{ client: "192.0.2.31", buckets: [20, 20] },
|
||||
],
|
||||
other: [0, 0],
|
||||
coverage: COVERAGE,
|
||||
};
|
||||
|
||||
const TYPES: StatsTypes = {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: UNTIL,
|
||||
types: [
|
||||
{ qtype: 1, count: 600 },
|
||||
{ qtype: 28, count: 300 },
|
||||
{ qtype: null, count: 100 },
|
||||
],
|
||||
coverage: COVERAGE,
|
||||
};
|
||||
|
||||
const ROUTES: StatsRoutes = {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: UNTIL,
|
||||
routes: [
|
||||
{ route: "upstream", source: "https://dns.example/dns-query", count: 500 },
|
||||
{ route: "blocked", source: null, count: 250 },
|
||||
{ route: "cache", source: null, count: 150 },
|
||||
{ route: "upstream", source: null, count: 100 },
|
||||
],
|
||||
coverage: COVERAGE,
|
||||
};
|
||||
|
||||
/** The same shapes an hour wide, so a period change is observable in every panel. */
|
||||
const HOUR = {
|
||||
totals: { ...TOTALS, period: "1h", since: UNTIL - 3600, queries: 12, blocked: 3, clients: 2 } as StatsTotals,
|
||||
timeseries: { ...SERIES, period: "1h", since: UNTIL - 3600, bucket_seconds: 60, buckets: [] } as StatsTimeseries,
|
||||
clients: { ...CLIENTS, period: "1h", since: UNTIL - 3600, clients: [], other: [] } as StatsClients,
|
||||
types: { ...TYPES, period: "1h", since: UNTIL - 3600, types: [] } as StatsTypes,
|
||||
routes: { ...ROUTES, period: "1h", since: UNTIL - 3600, routes: [] } as StatsRoutes,
|
||||
};
|
||||
|
||||
let healthBody: Health;
|
||||
let failing: Set<string>;
|
||||
/** The registered clients, as `/api/clients` answers them. */
|
||||
let registered: { ip: string; name: string; learned_name: string }[];
|
||||
let coverageComplete: boolean;
|
||||
/** Paths held in flight, so a test can look at the page while one is pending. */
|
||||
let delayed: Map<string, Promise<void>>;
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function withCoverage<T extends { coverage: typeof COVERAGE }>(body: T): T {
|
||||
return { ...body, coverage: { ...body.coverage, complete: coverageComplete } };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
healthBody = health();
|
||||
failing = new Set();
|
||||
registered = [];
|
||||
coverageComplete = true;
|
||||
delayed = new Map();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const hour = url.includes("period=1h");
|
||||
for (const [path, body] of [
|
||||
["/api/stats/timeseries", hour ? HOUR.timeseries : SERIES],
|
||||
["/api/stats/clients", hour ? HOUR.clients : CLIENTS],
|
||||
["/api/stats/types", hour ? HOUR.types : TYPES],
|
||||
["/api/stats/routes", hour ? HOUR.routes : ROUTES],
|
||||
["/api/stats", hour ? HOUR.totals : TOTALS],
|
||||
] as const) {
|
||||
if (!url.startsWith(path)) continue;
|
||||
if (failing.has(path)) return json({ error: "endpoint unavailable" }, 400);
|
||||
const held = delayed.get(path);
|
||||
if (held !== undefined) await held;
|
||||
return json(withCoverage(body));
|
||||
}
|
||||
if (url === "/api/clients") {
|
||||
return json({
|
||||
clients: registered.map((client, index) => ({
|
||||
id: index + 1,
|
||||
ip: client.ip,
|
||||
name: client.name,
|
||||
learned_name: client.learned_name,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: client.name !== "",
|
||||
first_seen: SINCE,
|
||||
last_seen: UNTIL,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (url === "/api/health") return 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());
|
||||
|
||||
function renderApp(path = "/overview") {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
function panel(name: string): HTMLElement {
|
||||
const heading = screen.getByRole("heading", { name });
|
||||
const section = heading.closest("section");
|
||||
if (section === null) throw new Error(`no panel for ${name}`);
|
||||
return section;
|
||||
}
|
||||
|
||||
test("the root path lands on Overview rather than aliasing it", async () => {
|
||||
const router = renderApp("/");
|
||||
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
||||
expect(router.state.location.pathname).toBe("/overview");
|
||||
});
|
||||
|
||||
test("every donut arc is outlined, so two slices of one hue still read as two", async () => {
|
||||
// Colour is a pure function of identity and so cannot rule out two slices of
|
||||
// one panel sharing a hue. The stroke is what stops neighbours from merging
|
||||
// into one shape, which makes it part of the contract rather than decoration.
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
|
||||
|
||||
const arcs = Array.from(panel("Query types").querySelectorAll("svg path"));
|
||||
expect(arcs).toHaveLength(3);
|
||||
for (const arc of arcs) {
|
||||
expect(arc.getAttribute("stroke-width")).toBe("1");
|
||||
// The panel's own surface colour, as a token reference.
|
||||
expect(arc.getAttribute("stroke")).toMatch(/^var\(--/);
|
||||
}
|
||||
});
|
||||
|
||||
test("a slow endpoint does not hold the page back: the panels that answered render beside it", async () => {
|
||||
// Through the real route, which is the point: the loader starts the five
|
||||
// requests and awaits none of them. If it awaited, the router would hold the
|
||||
// whole page until the slowest answered and this would time out on the tiles.
|
||||
let release = () => {};
|
||||
delayed.set("/api/stats/routes", new Promise<void>((resolve) => (release = resolve)));
|
||||
|
||||
renderApp();
|
||||
|
||||
// The tiles and both charts are readable while the routes request is still
|
||||
// in flight, and the panel waiting on it says so for itself.
|
||||
await screen.findByText("1,000");
|
||||
expect(within(panel("Queries over time")).getAllByText("Blocked").length).toBeGreaterThan(0);
|
||||
expect(within(panel("Client activity over time")).getAllByText("192.0.2.30")).toHaveLength(2);
|
||||
expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2);
|
||||
expect(within(panel("Upstream servers")).getByRole("status").textContent).toBe("Loading…");
|
||||
|
||||
release();
|
||||
await waitFor(() => expect(within(panel("Upstream servers")).queryByRole("status")).toBeNull());
|
||||
});
|
||||
|
||||
test("a registered client is named in the chart, an unregistered one keeps its address", async () => {
|
||||
// The fixture's two clients: one registered with a typed name, one the clients
|
||||
// list has never seen.
|
||||
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
|
||||
renderApp();
|
||||
const chart = await waitFor(() => panel("Client activity over time"));
|
||||
|
||||
// Legend and the hidden table both, since the table is what a screen reader
|
||||
// gets instead of the graphic and the two must not name one client differently.
|
||||
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
|
||||
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
|
||||
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a client named only by reverse DNS is named by it too", async () => {
|
||||
registered = [{ ip: "192.0.2.31", name: "", learned_name: "laptop.lan" }];
|
||||
renderApp();
|
||||
const chart = await waitFor(() => panel("Client activity over time"));
|
||||
|
||||
await waitFor(() => expect(within(chart).getAllByText("laptop.lan")).toHaveLength(2));
|
||||
});
|
||||
|
||||
test("naming a client does not recolour its series", async () => {
|
||||
// The rename the palette must not notice: the swatch beside "kitchen-pi" is
|
||||
// the colour of the address it was drawn under, not of the label on screen.
|
||||
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
|
||||
renderApp();
|
||||
const chart = await waitFor(() => panel("Client activity over time"));
|
||||
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
|
||||
|
||||
const item = within(chart).getAllByText("kitchen-pi")[0].closest("li") as HTMLElement;
|
||||
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
|
||||
expect(swatch.getAttribute("style")).toContain(seriesColor(clientKey("192.0.2.30")));
|
||||
});
|
||||
|
||||
test("the client chart names Other even in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. Dropping it from the legend there
|
||||
// would tell the reader the two named clients were every client.
|
||||
renderApp();
|
||||
await screen.findByRole("heading", { name: "Client activity over time" });
|
||||
|
||||
const chart = screen.getByRole("heading", { name: "Client activity over time" }).closest("section");
|
||||
expect(chart).toBeTruthy();
|
||||
// Twice each: the legend swatch and the column header of the table a screen
|
||||
// reader gets instead of the graphic.
|
||||
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("Other")).toHaveLength(2));
|
||||
expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => {
|
||||
renderApp();
|
||||
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
||||
await screen.findByText("1,000");
|
||||
|
||||
for (const name of ["Queries over time", "Client activity over time", "Query types", "Upstream servers"]) {
|
||||
expect(screen.getByRole("heading", { name })).toBeTruthy();
|
||||
}
|
||||
// The sections the layout ruling removed, and the widgets the Dashboard lost.
|
||||
expect(screen.queryByRole("heading", { name: "Current status" })).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "Active issues" })).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "Activity over a period" })).toBeNull();
|
||||
expect(screen.queryByText("Storage now")).toBeNull();
|
||||
expect(screen.queryByRole("columnheader", { name: "Upstream" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the four tiles report the window, and each links where its number leads", async () => {
|
||||
renderApp();
|
||||
const tiles = within((await screen.findByText("1,000")).closest("dl") as HTMLElement);
|
||||
expect(tiles.getByText("250")).toBeTruthy();
|
||||
expect(tiles.getByText("25.0%")).toBeTruthy();
|
||||
expect(tiles.getByText("7")).toBeTruthy();
|
||||
expect(tiles.getByText("2.3 ms")).toBeTruthy();
|
||||
|
||||
// The bounds are the ones the stats response returned, not ones computed here.
|
||||
const queries = new URLSearchParams(
|
||||
screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "",
|
||||
);
|
||||
expect(queries.get("mode")).toBe("history");
|
||||
expect(queries.get("since")).toBe(String(SINCE));
|
||||
expect(queries.get("until")).toBe(String(UNTIL));
|
||||
expect(queries.get("blocked")).toBeNull();
|
||||
|
||||
const blocked = new URLSearchParams(
|
||||
screen.getByRole("link", { name: "Open blocked queries" }).getAttribute("href")?.split("?")[1] ?? "",
|
||||
);
|
||||
expect(blocked.get("blocked")).toBe("true");
|
||||
expect(blocked.get("since")).toBe(String(SINCE));
|
||||
|
||||
expect(screen.getByRole("link", { name: "Manage clients" }).getAttribute("href")).toBe("/clients");
|
||||
// Average response time has no rows behind it to open.
|
||||
expect(screen.queryByRole("link", { name: /average/i })).toBeNull();
|
||||
});
|
||||
|
||||
test("both donuts name every entry, nulls included, and disambiguate a nameless source", async () => {
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
|
||||
const types = within(panel("Query types"));
|
||||
expect(types.getByRole("rowheader", { name: "A" })).toBeTruthy();
|
||||
expect(types.getByRole("rowheader", { name: "AAAA" })).toBeTruthy();
|
||||
// A query whose type was never recorded is its own entry, not a dropped row.
|
||||
expect(types.getByRole("rowheader", { name: "Unknown" })).toBeTruthy();
|
||||
|
||||
const routes = within(panel("Upstream servers"));
|
||||
expect(routes.getByRole("rowheader", { name: "https://dns.example/dns-query (Upstream)" })).toBeTruthy();
|
||||
expect(routes.getByRole("rowheader", { name: "Blocked" })).toBeTruthy();
|
||||
expect(routes.getByRole("rowheader", { name: "Cache" })).toBeTruthy();
|
||||
// An upstream row with no recorded resolver reads as Unknown, qualified by its kind.
|
||||
expect(routes.getByRole("rowheader", { name: "Unknown (Upstream)" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the donut ring is decoration; the legend and the hidden table are the accessible surface", async () => {
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
|
||||
const svg = panel("Query types").querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(svg?.getAttribute("focusable")).toBe("false");
|
||||
expect(within(panel("Query types")).getByRole("table")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an empty window says so in every panel instead of drawing nothing", async () => {
|
||||
renderApp("/overview?period=1h");
|
||||
await screen.findByText("12");
|
||||
|
||||
// The two donuts and the client chart; the query-volume chart says it too.
|
||||
expect(screen.getAllByText("No queries in this period.").length).toBe(4);
|
||||
});
|
||||
|
||||
test("a deep link opens on the period it names", async () => {
|
||||
renderApp("/overview?period=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");
|
||||
});
|
||||
|
||||
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
|
||||
const router = renderApp("/overview?period=90d");
|
||||
await screen.findByText("1,000");
|
||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(router.state.location.search).toEqual({});
|
||||
});
|
||||
|
||||
test("the picker rescopes every panel and writes the period into the url", async () => {
|
||||
const router = renderApp();
|
||||
await screen.findByText("1,000");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
||||
|
||||
await screen.findByText("12");
|
||||
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
|
||||
// No panel is left describing the period the reader left.
|
||||
expect(screen.queryByText("1,000")).toBeNull();
|
||||
});
|
||||
|
||||
test("one failing panel keeps its own error and leaves the rest of the page standing", async () => {
|
||||
failing.add("/api/stats/routes");
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
|
||||
await waitFor(() => expect(within(panel("Upstream servers")).getByText("endpoint unavailable")).toBeTruthy());
|
||||
expect(within(panel("Upstream servers")).getByRole("button", { name: "Retry" })).toBeTruthy();
|
||||
// A failed donut never blanks the charts.
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: /client activity over time/i })).toBeTruthy();
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
});
|
||||
|
||||
test("an incomplete window states its watermark once for the whole page", async () => {
|
||||
coverageComplete = false;
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
expect(screen.getAllByText(/Query history is available from/)).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Overview: what the resolver did over a period the reader chooses, in the
|
||||
* layout Pi-hole's dashboard established — four totals, two full-width charts,
|
||||
* two breakdown donuts. Nothing on this page is a current-state readout; the
|
||||
* five health conditions live on Diagnostics, and protection lives in the
|
||||
* sidebar beside its control.
|
||||
*
|
||||
* The period is URL state, so a view is a link: `/overview?period=1h` opens
|
||||
* exactly what the sender was reading.
|
||||
*
|
||||
* Every panel reads the same window (`overviewWindow.ts`) and renders on its
|
||||
* own. A donut whose request failed shows its own error while the charts keep
|
||||
* their data, and no two panels ever describe different spans.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import CoverageNotice from "@/lib/CoverageNotice";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
import type { Period, StatsRoutes, StatsTypes } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import ClientChart from "./ClientChart";
|
||||
import Donut from "./Donut";
|
||||
import StatTiles from "./StatTiles";
|
||||
import TimeseriesChart from "./TimeseriesChart";
|
||||
import type { DonutSlice } from "./donutLayout";
|
||||
import { useOverviewWindow, type Panel } from "./overviewWindow";
|
||||
import { DEFAULT_PERIOD, PERIODS } from "./period";
|
||||
import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
|
||||
|
||||
/**
|
||||
* Where the two donuts stop competing for width and sit side by side. `Donut`
|
||||
* carries the same query for the alignment it switches at that width; StyleX
|
||||
* requires the string to be a literal in the module that uses it, so the two
|
||||
* agree by inspection rather than by sharing a constant.
|
||||
*/
|
||||
const TWO_COLUMN = "@media (min-width: 1280px)";
|
||||
|
||||
const ROUTE_LABELS = {
|
||||
blocked: "Blocked",
|
||||
cache: "Cache",
|
||||
local: "Local",
|
||||
rejected: "Rejected",
|
||||
upstream: "Upstream",
|
||||
forward_zone: "Forward zone",
|
||||
} as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
page: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
},
|
||||
headingRow: {
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
donutRow: {
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: { default: "minmax(0, 1fr)", [TWO_COLUMN]: "repeat(2, minmax(0, 1fr))" },
|
||||
},
|
||||
loading: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One panel's three states. Loading and error are the panel's own: a failure
|
||||
* here never reaches past this box, which is what keeps a failed donut from
|
||||
* blanking the charts beside it.
|
||||
*/
|
||||
function PanelBody<T>({ panel, children }: { panel: Panel<T>; children: (data: T) => React.ReactNode }) {
|
||||
if (panel.status === "error") return <InlineError error={panel.error} onRetry={panel.retry} />;
|
||||
if (panel.status === "loading") {
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.loading, shared.pulse)}>
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <>{children(panel.data)}</>;
|
||||
}
|
||||
|
||||
function typeSlices(data: StatsTypes): DonutSlice[] {
|
||||
return data.types.map((row) => ({
|
||||
key: qtypeKey(row.qtype),
|
||||
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
|
||||
value: row.count,
|
||||
color: seriesColor(qtypeKey(row.qtype)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Route slices. A row that names a resolver or a zone is labelled by that name
|
||||
* with the route kind as secondary text, because one name can legitimately
|
||||
* appear under two kinds and two rows can both be "Unknown". The four
|
||||
* source-less kinds are their own label and need no qualifier.
|
||||
*/
|
||||
function routeSlices(data: StatsRoutes): DonutSlice[] {
|
||||
return data.routes.map((row) => {
|
||||
const named = row.route === "upstream" || row.route === "forward_zone";
|
||||
return {
|
||||
key: routeKey(row.route, row.source),
|
||||
label: named ? (row.source ?? "Unknown") : ROUTE_LABELS[row.route],
|
||||
...(named ? { secondary: ROUTE_LABELS[row.route] } : {}),
|
||||
value: row.count,
|
||||
color: seriesColor(routeKey(row.route, row.source)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
|
||||
const navigate = useNavigate({ from: "/overview" });
|
||||
const overview = useOverviewWindow(period);
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.page)}>
|
||||
<div {...stylex.props(styles.headingRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Overview</h1>
|
||||
<PeriodPicker
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PanelBody panel={overview.totals}>{(totals) => <StatTiles stats={totals} />}</PanelBody>
|
||||
|
||||
{/* One notice for the page: every panel is judged against the same window,
|
||||
so a second copy would only repeat this sentence. */}
|
||||
{overview.coverage !== null && <CoverageNotice coverage={overview.coverage} />}
|
||||
|
||||
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
|
||||
Queries over time
|
||||
</h2>
|
||||
<PanelBody panel={overview.timeseries}>{(data) => <TimeseriesChart data={data} />}</PanelBody>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
|
||||
Client activity over time
|
||||
</h2>
|
||||
<PanelBody panel={overview.clients}>{(data) => <ClientChart data={data} />}</PanelBody>
|
||||
</section>
|
||||
|
||||
<div {...stylex.props(styles.donutRow)}>
|
||||
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
|
||||
Query types
|
||||
</h2>
|
||||
<PanelBody panel={overview.types}>
|
||||
{(data) => <Donut slices={typeSlices(data)} caption="Queries by DNS type" unit="Queries" />}
|
||||
</PanelBody>
|
||||
</section>
|
||||
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
|
||||
Upstream servers
|
||||
</h2>
|
||||
<PanelBody panel={overview.routes}>
|
||||
{(data) => (
|
||||
<Donut
|
||||
slices={routeSlices(data)}
|
||||
caption="Queries by how they were answered"
|
||||
unit="Queries"
|
||||
/>
|
||||
)}
|
||||
</PanelBody>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* The window's four headline numbers, each with the way into the rows behind it.
|
||||
*
|
||||
* Neutral chrome throughout: no coloured accents, no per-tile tone. Emphasis is
|
||||
* typographic, so the eye ranks the figures rather than the panels, and a tile
|
||||
* never implies a state it is not reporting.
|
||||
*
|
||||
* The Activity links carry the bounds the **stats response** returned, not
|
||||
* bounds computed here — a client-computed window would send the reader to a
|
||||
* slightly different span than the one they were just reading.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
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, the whole set of four in one row from `md`. */
|
||||
grid: {
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 768px)": "repeat(4, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
tile: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.125rem",
|
||||
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,
|
||||
},
|
||||
valueRow: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
value: {
|
||||
fontSize: "1.875rem",
|
||||
lineHeight: "2.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
detail: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
footer: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
link: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
});
|
||||
|
||||
function percentOf(part: number, total: number): string | null {
|
||||
if (total === 0) return null;
|
||||
return `${((part / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function Tile({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
footer,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string | null;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div {...stylex.props(styles.tile)}>
|
||||
<dt {...stylex.props(styles.label)}>{label}</dt>
|
||||
<dd {...stylex.props(styles.valueRow)}>
|
||||
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
|
||||
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
|
||||
</dd>
|
||||
{footer !== undefined && <div {...stylex.props(styles.footer)}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatTiles({ stats }: { stats: StatsTotals }) {
|
||||
const window = {
|
||||
mode: "history" as const,
|
||||
since: stats.since,
|
||||
until: stats.until,
|
||||
domain: undefined,
|
||||
client: undefined,
|
||||
};
|
||||
return (
|
||||
<dl {...stylex.props(styles.grid)}>
|
||||
<Tile
|
||||
label="Queries"
|
||||
value={numberFormat.format(stats.queries)}
|
||||
footer={
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ ...window, blocked: undefined }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Open in Activity
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
label="Blocked"
|
||||
value={numberFormat.format(stats.blocked)}
|
||||
detail={percentOf(stats.blocked, stats.queries)}
|
||||
footer={
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ ...window, blocked: true }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Open blocked queries
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
label="Clients"
|
||||
value={numberFormat.format(stats.clients)}
|
||||
footer={
|
||||
<Link to="/clients" {...stylex.props(styles.link, shared.focusRing)}>
|
||||
Manage clients
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
label="Avg response"
|
||||
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
|
||||
/>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import type { StatsTimeseries } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import TimeseriesChart from "./TimeseriesChart";
|
||||
|
||||
const SINCE = 1_700_000_000;
|
||||
|
||||
function timeseries(bucketCount: number): StatsTimeseries {
|
||||
return {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: SINCE + bucketCount * 1800,
|
||||
bucket_seconds: 1800,
|
||||
coverage: { complete: true, available_since: SINCE },
|
||||
buckets: Array.from({ length: bucketCount }, (_, i) => ({
|
||||
ts: SINCE + i * 1800,
|
||||
queries: i + 1,
|
||||
blocked: 1,
|
||||
cached: 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The element wearing the shared hidden style, found by its compiled classes. */
|
||||
function hiddenElement(container: HTMLElement): Element | null {
|
||||
const classes = stylex.props(shared.srOnly).className?.split(" ").filter(Boolean) ?? [];
|
||||
expect(classes.length).toBeGreaterThan(0);
|
||||
return container.querySelector(classes.map((name) => `.${name}`).join(""));
|
||||
}
|
||||
|
||||
test("the data table is the SVG's accessible equivalent", () => {
|
||||
render(<TimeseriesChart data={timeseries(3)} />);
|
||||
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
const table = screen.getByRole("table", { name: "Queries per time bucket" });
|
||||
expect(within(table).getAllByRole("row").length).toBe(4);
|
||||
});
|
||||
|
||||
/**
|
||||
* `overflow` does not apply to a table box and `height` on one is a minimum, so
|
||||
* the hidden style has to sit on a block container wrapping the table. Worn by
|
||||
* the table itself it clips the paint but not the layout, and 48 invisible rows
|
||||
* push the document's scroll height a screen past the app shell.
|
||||
*/
|
||||
test("the hidden data table is clipped by a block wrapper, not by the table itself", () => {
|
||||
const { container } = render(<TimeseriesChart data={timeseries(48)} />);
|
||||
|
||||
const hidden = hiddenElement(container);
|
||||
expect(hidden?.tagName).toBe("DIV");
|
||||
expect(hidden?.querySelector("table")).not.toBeNull();
|
||||
});
|
||||
+24
-22
@@ -292,29 +292,31 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<table {...stylex.props(shared.srOnly)}>
|
||||
<caption>Queries per time bucket</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Queries</th>
|
||||
<th scope="col">Blocked</th>
|
||||
<th scope="col">Cached</th>
|
||||
<th scope="col">Other</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{layout.bars.map((bar) => (
|
||||
<tr key={bar.bucket.ts}>
|
||||
<th scope="row">{formatTime(bar.bucket.ts)}</th>
|
||||
<td>{bar.bucket.queries}</td>
|
||||
<td>{bar.bucket.blocked}</td>
|
||||
<td>{bar.bucket.cached}</td>
|
||||
<td>{bar.other}</td>
|
||||
<div {...stylex.props(shared.srOnly)}>
|
||||
<table>
|
||||
<caption>Queries per time bucket</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Queries</th>
|
||||
<th scope="col">Blocked</th>
|
||||
<th scope="col">Cached</th>
|
||||
<th scope="col">Other</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{layout.bars.map((bar) => (
|
||||
<tr key={bar.bucket.ts}>
|
||||
<th scope="row">{formatTime(bar.bucket.ts)}</th>
|
||||
<td>{bar.bucket.queries}</td>
|
||||
<td>{bar.bucket.blocked}</td>
|
||||
<td>{bar.bucket.cached}</td>
|
||||
<td>{bar.other}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+87
-6
@@ -52,13 +52,97 @@ export function isEmptyTimeseries(buckets: Bucket[]): boolean {
|
||||
return buckets.every((bucket) => bucket.queries === 0);
|
||||
}
|
||||
|
||||
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
|
||||
const plot: Rect = {
|
||||
function plotRect(width: number, height: number): Rect {
|
||||
return {
|
||||
x: MARGIN.left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||
height: Math.max(0, height - MARGIN.top - MARGIN.bottom),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns share one x-axis label. A 30-day window is 30 columns and a
|
||||
* 1-hour window is 60, so at narrow widths the labels have to thin out rather
|
||||
* than overprint each other.
|
||||
*/
|
||||
function labelStepFor(count: number, plotWidth: number): number {
|
||||
if (count === 0 || plotWidth <= 0) return 1;
|
||||
return Math.max(1, Math.ceil((count * MIN_X_LABEL_PX) / plotWidth));
|
||||
}
|
||||
|
||||
/** One stacked column: the series values in the order the caller stacks them. */
|
||||
export interface StackedColumn {
|
||||
ts: number;
|
||||
total: number;
|
||||
slot: Rect;
|
||||
segments: Rect[];
|
||||
}
|
||||
|
||||
export interface StackedLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
plot: Rect;
|
||||
scaleMax: number;
|
||||
columns: StackedColumn[];
|
||||
yTicks: { value: number; y: number }[];
|
||||
xTicks: { ts: number; x: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same geometry as the query-volume chart, for an arbitrary number of
|
||||
* series. The scale comes from the tallest column's own total, because every
|
||||
* series here is a disjoint part of the whole rather than a highlighted subset
|
||||
* of a separately reported total.
|
||||
*/
|
||||
export function layoutStacked(
|
||||
columns: { ts: number; values: number[] }[],
|
||||
width: number,
|
||||
height: number,
|
||||
): StackedLayout {
|
||||
const plot = plotRect(width, height);
|
||||
const totals = columns.map((column) => column.values.reduce((sum, value) => sum + value, 0));
|
||||
const tickValues = niceTicks(Math.max(0, ...totals));
|
||||
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
|
||||
const baseline = plot.y + plot.height;
|
||||
const toHeight = (value: number) => (value / scaleMax) * plot.height;
|
||||
|
||||
const slotWidth = columns.length > 0 ? plot.width / columns.length : 0;
|
||||
const barWidth = Math.max(1, slotWidth - BAR_GAP);
|
||||
|
||||
const laidOut: StackedColumn[] = columns.map((column, i) => {
|
||||
const slotX = plot.x + i * slotWidth;
|
||||
const barX = slotX + (slotWidth - barWidth) / 2;
|
||||
let top = baseline;
|
||||
const segments = column.values.map((value) => {
|
||||
const segmentHeight = toHeight(value);
|
||||
top -= segmentHeight;
|
||||
return { x: barX, y: top, width: barWidth, height: segmentHeight };
|
||||
});
|
||||
return {
|
||||
ts: column.ts,
|
||||
total: totals[i],
|
||||
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
|
||||
segments,
|
||||
};
|
||||
});
|
||||
|
||||
const step = labelStepFor(columns.length, plot.width);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
plot,
|
||||
scaleMax,
|
||||
columns: laidOut,
|
||||
yTicks: tickValues.map((value) => ({ value, y: baseline - toHeight(value) })),
|
||||
xTicks: laidOut
|
||||
.filter((_, i) => i % step === 0)
|
||||
.map((column) => ({ ts: column.ts, x: column.slot.x + column.slot.width / 2 })),
|
||||
};
|
||||
}
|
||||
|
||||
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
|
||||
const plot = plotRect(width, height);
|
||||
const maxQueries = buckets.reduce((max, bucket) => Math.max(max, bucket.queries), 0);
|
||||
const tickValues = niceTicks(maxQueries);
|
||||
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
|
||||
@@ -89,10 +173,7 @@ export function layoutTimeseries(buckets: Bucket[], width: number, height: numbe
|
||||
|
||||
const yTicks = tickValues.map((value) => ({ value, y: baseline - toHeight(value) }));
|
||||
|
||||
const labelStep =
|
||||
buckets.length > 0 && plot.width > 0
|
||||
? Math.max(1, Math.ceil((buckets.length * MIN_X_LABEL_PX) / plot.width))
|
||||
: 1;
|
||||
const labelStep = labelStepFor(buckets.length, plot.width);
|
||||
const xTicks = bars
|
||||
.filter((_, i) => i % labelStep === 0)
|
||||
.map((bar) => ({ ts: bar.bucket.ts, x: bar.slot.x + bar.slot.width / 2 }));
|
||||
@@ -0,0 +1,47 @@
|
||||
import { layoutDonut, type DonutSlice } from "./donutLayout";
|
||||
|
||||
function slice(key: string, value: number): DonutSlice {
|
||||
return { key, label: key, value, color: "#000000" };
|
||||
}
|
||||
|
||||
test("an empty breakdown has no total and no arcs to draw", () => {
|
||||
expect(layoutDonut([], 100, 20)).toEqual({ size: 100, total: 0, arcs: [] });
|
||||
});
|
||||
|
||||
test("a breakdown of nothing but zeroes is empty, not a division by zero", () => {
|
||||
const layout = layoutDonut([slice("a", 0), slice("b", 0)], 100, 20);
|
||||
expect(layout.total).toBe(0);
|
||||
expect(layout.arcs).toEqual([]);
|
||||
});
|
||||
|
||||
test("zero-valued entries are dropped rather than legended at 0%", () => {
|
||||
const layout = layoutDonut([slice("a", 3), slice("b", 0), slice("c", 1)], 100, 20);
|
||||
expect(layout.arcs.map((arc) => arc.slice.key)).toEqual(["a", "c"]);
|
||||
expect(layout.total).toBe(4);
|
||||
});
|
||||
|
||||
test("shares are of the drawn total and add up to one", () => {
|
||||
const layout = layoutDonut([slice("a", 3), slice("b", 1)], 100, 20);
|
||||
expect(layout.arcs.map((arc) => arc.share)).toEqual([0.75, 0.25]);
|
||||
});
|
||||
|
||||
test("slices keep the order they were ranked in, starting at twelve o'clock", () => {
|
||||
const layout = layoutDonut([slice("a", 1), slice("b", 1)], 100, 20);
|
||||
expect(layout.arcs[0].d.startsWith("M 50.000 0.000")).toBe(true);
|
||||
// The second slice begins where the first ended, half a turn round.
|
||||
expect(layout.arcs[1].d.startsWith("M 50.000 100.000")).toBe(true);
|
||||
});
|
||||
|
||||
test("a slice over half the ring takes the large-arc flag", () => {
|
||||
const layout = layoutDonut([slice("a", 9), slice("b", 1)], 100, 20);
|
||||
expect(layout.arcs[0].d).toContain("A 50 50 0 1 1");
|
||||
expect(layout.arcs[1].d).toContain("A 50 50 0 0 1");
|
||||
});
|
||||
|
||||
test("a single entry is a closed ring, not a zero-length arc that draws nothing", () => {
|
||||
const layout = layoutDonut([slice("only", 7)], 100, 20);
|
||||
expect(layout.arcs).toHaveLength(1);
|
||||
expect(layout.arcs[0].share).toBe(1);
|
||||
// Two half arcs out and two back: a lone `A` from a point to itself is a no-op.
|
||||
expect(layout.arcs[0].d.match(/A /g)).toHaveLength(4);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Annulus geometry for the two breakdown donuts. Pure, so the arithmetic that
|
||||
* decides whether a slice closes correctly is testable without a DOM.
|
||||
*/
|
||||
|
||||
export interface DonutSlice {
|
||||
/** Semantic identity: the React key, the colour key and the legend's identity. */
|
||||
key: string;
|
||||
label: string;
|
||||
/** Disambiguates entries whose labels collide — two "Unknown"s, one name on two route kinds. */
|
||||
secondary?: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface DonutArc {
|
||||
slice: DonutSlice;
|
||||
/** Of the whole, 0 to 1. */
|
||||
share: number;
|
||||
d: string;
|
||||
}
|
||||
|
||||
export interface DonutLayout {
|
||||
size: number;
|
||||
total: number;
|
||||
arcs: DonutArc[];
|
||||
}
|
||||
|
||||
const START_ANGLE = -Math.PI / 2;
|
||||
|
||||
function point(center: number, radius: number, angle: number): string {
|
||||
return `${(center + radius * Math.cos(angle)).toFixed(3)} ${(center + radius * Math.sin(angle)).toFixed(3)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole-circle slice cannot be drawn as one arc — start and end coincide, and
|
||||
* the renderer draws nothing at all — so the ring is two half arcs.
|
||||
*/
|
||||
function fullRing(center: number, outer: number, inner: number): string {
|
||||
const top = `${center} ${center - outer}`;
|
||||
const bottom = `${center} ${center + outer}`;
|
||||
const innerTop = `${center} ${center - inner}`;
|
||||
const innerBottom = `${center} ${center + inner}`;
|
||||
return [
|
||||
`M ${top}`,
|
||||
`A ${outer} ${outer} 0 0 1 ${bottom}`,
|
||||
`A ${outer} ${outer} 0 0 1 ${top}`,
|
||||
`M ${innerTop}`,
|
||||
`A ${inner} ${inner} 0 0 0 ${innerBottom}`,
|
||||
`A ${inner} ${inner} 0 0 0 ${innerTop}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Slices in the order given — the caller has already ranked them — starting at
|
||||
* twelve o'clock and running clockwise. Zero-valued slices are dropped: they
|
||||
* have no arc to draw, and a legend entry reading 0 is noise.
|
||||
*/
|
||||
export function layoutDonut(slices: DonutSlice[], size: number, thickness: number): DonutLayout {
|
||||
const drawn = slices.filter((slice) => slice.value > 0);
|
||||
const total = drawn.reduce((sum, slice) => sum + slice.value, 0);
|
||||
if (total <= 0) return { size, total: 0, arcs: [] };
|
||||
|
||||
const center = size / 2;
|
||||
const outer = center;
|
||||
const inner = Math.max(0, center - thickness);
|
||||
|
||||
if (drawn.length === 1) {
|
||||
return {
|
||||
size,
|
||||
total,
|
||||
arcs: [{ slice: drawn[0], share: 1, d: fullRing(center, outer, inner) }],
|
||||
};
|
||||
}
|
||||
|
||||
let angle = START_ANGLE;
|
||||
const arcs = drawn.map((slice) => {
|
||||
const share = slice.value / total;
|
||||
const sweep = share * Math.PI * 2;
|
||||
const end = angle + sweep;
|
||||
const large = sweep > Math.PI ? 1 : 0;
|
||||
const d = [
|
||||
`M ${point(center, outer, angle)}`,
|
||||
`A ${outer} ${outer} 0 ${large} 1 ${point(center, outer, end)}`,
|
||||
`L ${point(center, inner, end)}`,
|
||||
`A ${inner} ${inner} 0 ${large} 0 ${point(center, inner, angle)}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
angle = end;
|
||||
return { slice, share, d };
|
||||
});
|
||||
|
||||
return { size, total, arcs };
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Window coherence across the five Overview requests, migrated from the
|
||||
* two-request `activityWindow` this replaces. Every behaviour that hook pinned
|
||||
* is pinned here — the identity, the one retry per mismatch episode, the
|
||||
* terminal error, the discarded previous-period pair and the stale completion
|
||||
* that must not speak — now over five endpoints and with the watermark in the
|
||||
* identity, plus the per-panel isolation the layout added.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import type { Coverage, Period } from "@/lib/types";
|
||||
import {
|
||||
newerWindow,
|
||||
sameWindow,
|
||||
useOverviewWindow,
|
||||
windowIdOf,
|
||||
OVERVIEW_ENDPOINTS,
|
||||
type OverviewEndpoint,
|
||||
} from "./overviewWindow";
|
||||
|
||||
const SINCE = Date.UTC(2026, 0, 1, 0, 0) / 1000;
|
||||
const UNTIL = Date.UTC(2026, 0, 2, 0, 0) / 1000;
|
||||
const COVERAGE: Coverage = { complete: true, available_since: SINCE };
|
||||
|
||||
/** Where each endpoint's body currently ends, and what watermark it admits. */
|
||||
interface Bounds {
|
||||
until: number;
|
||||
availableSince: number;
|
||||
}
|
||||
|
||||
const PATHS: Record<OverviewEndpoint, string> = {
|
||||
totals: "/api/stats?period=",
|
||||
timeseries: "/api/stats/timeseries?period=",
|
||||
clients: "/api/stats/clients?period=",
|
||||
types: "/api/stats/types?period=",
|
||||
routes: "/api/stats/routes?period=",
|
||||
};
|
||||
|
||||
let bounds: Record<OverviewEndpoint, Bounds>;
|
||||
let failing: Set<OverviewEndpoint>;
|
||||
let calls: Record<OverviewEndpoint, number>;
|
||||
/** Endpoints that answer for the page's window from their second call onward. */
|
||||
let catchUp: Set<OverviewEndpoint>;
|
||||
/** Held to keep one answer in flight while the test moves the page on. */
|
||||
let hold: { promise: Promise<void>; release: () => void } | null;
|
||||
|
||||
function endpointOf(url: string): OverviewEndpoint | null {
|
||||
// Longest prefix first: `/api/stats?` and `/api/stats/…` share a stem.
|
||||
for (const endpoint of ["timeseries", "clients", "types", "routes", "totals"] as const) {
|
||||
if (url.startsWith(PATHS[endpoint])) return endpoint;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function body(endpoint: OverviewEndpoint, period: Period): unknown {
|
||||
const { until, availableSince } = bounds[endpoint];
|
||||
const shared = { period, since: SINCE, until, coverage: { ...COVERAGE, available_since: availableSince } };
|
||||
switch (endpoint) {
|
||||
case "totals":
|
||||
return { ...shared, queries: 10, blocked: 2, clients: 1, avg_response_time_us: 1000 };
|
||||
case "timeseries":
|
||||
return { ...shared, bucket_seconds: 3600, buckets: [] };
|
||||
case "clients":
|
||||
return { ...shared, bucket_seconds: 3600, clients: [], other: [] };
|
||||
case "types":
|
||||
return { ...shared, types: [] };
|
||||
case "routes":
|
||||
return { ...shared, routes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bounds = {
|
||||
totals: { until: UNTIL, availableSince: SINCE },
|
||||
timeseries: { until: UNTIL, availableSince: SINCE },
|
||||
clients: { until: UNTIL, availableSince: SINCE },
|
||||
types: { until: UNTIL, availableSince: SINCE },
|
||||
routes: { until: UNTIL, availableSince: SINCE },
|
||||
};
|
||||
failing = new Set();
|
||||
catchUp = new Set();
|
||||
hold = null;
|
||||
calls = { totals: 0, timeseries: 0, clients: 0, types: 0, routes: 0 };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const endpoint = endpointOf(url);
|
||||
if (endpoint === null) return json({ error: "not stubbed" }, 404);
|
||||
calls[endpoint] += 1;
|
||||
if (failing.has(endpoint)) return json({ error: "endpoint unavailable" }, 400);
|
||||
if (catchUp.has(endpoint) && calls[endpoint] >= 2)
|
||||
bounds[endpoint] = { until: UNTIL, availableSince: SINCE };
|
||||
const period = (new URLSearchParams(url.split("?")[1]).get("period") ?? "24h") as Period;
|
||||
// Built before the wait, so a held answer carries what its own request
|
||||
// would have returned rather than what the page has moved on to.
|
||||
const payload = json(body(endpoint, period));
|
||||
if (hold !== null && endpoint === "routes" && calls.routes === 2) await hold.promise;
|
||||
return payload;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function Probe({ period }: { period: Period }) {
|
||||
const overview = useOverviewWindow(period);
|
||||
return (
|
||||
<ul>
|
||||
{OVERVIEW_ENDPOINTS.map((endpoint) => {
|
||||
const panel = overview[endpoint];
|
||||
const detail =
|
||||
panel.status === "ready"
|
||||
? `${panel.data.period}@${panel.data.until}/${panel.data.coverage.available_since}`
|
||||
: panel.status === "error"
|
||||
? (panel.error as Error).message
|
||||
: "";
|
||||
return <li key={endpoint}>{`${endpoint}:${panel.status}:${detail}`}</li>;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function renderProbe(period: Period = "24h") {
|
||||
const client = createQueryClient();
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe period={period} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return {
|
||||
rerenderWith: (next: Period) =>
|
||||
view.rerender(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe period={next} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function line(endpoint: OverviewEndpoint): string {
|
||||
const item = screen.getAllByRole("listitem").find((element) => element.textContent?.startsWith(`${endpoint}:`));
|
||||
if (item === undefined) throw new Error(`no probe line for ${endpoint}`);
|
||||
return item.textContent ?? "";
|
||||
}
|
||||
|
||||
test("the window identity is the period, both bounds and the watermark together", () => {
|
||||
const base = { period: "24h" as const, since: SINCE, until: UNTIL, availableSince: SINCE };
|
||||
expect(sameWindow(base, { ...base })).toBe(true);
|
||||
expect(sameWindow(base, { ...base, period: "1h" })).toBe(false);
|
||||
expect(sameWindow(base, { ...base, since: SINCE - 1 })).toBe(false);
|
||||
expect(sameWindow(base, { ...base, until: UNTIL + 1 })).toBe(false);
|
||||
// The bounds agree and the answers still describe different windows: a prune
|
||||
// between the two requests moved what the same span can be answered for.
|
||||
expect(sameWindow(base, { ...base, availableSince: SINCE + 60 })).toBe(false);
|
||||
});
|
||||
|
||||
test("the newer until wins, and for equal bounds the later watermark does", () => {
|
||||
const base = { period: "24h" as const, since: SINCE, until: UNTIL, availableSince: SINCE };
|
||||
expect(newerWindow(base, { ...base, until: UNTIL + 60 }).until).toBe(UNTIL + 60);
|
||||
expect(newerWindow({ ...base, until: UNTIL + 60 }, base).until).toBe(UNTIL + 60);
|
||||
expect(newerWindow(base, { ...base, availableSince: SINCE + 60 }).availableSince).toBe(SINCE + 60);
|
||||
// A newer watermark does not outrank an older window's later bound.
|
||||
expect(newerWindow({ ...base, until: UNTIL + 60 }, { ...base, availableSince: SINCE + 60 }).until).toBe(UNTIL + 60);
|
||||
});
|
||||
|
||||
test("windowIdOf reads the four fields off any of the five bodies", () => {
|
||||
expect(windowIdOf({ period: "7d", since: 1, until: 2, coverage: { complete: false, available_since: 3 } })).toEqual(
|
||||
{
|
||||
period: "7d",
|
||||
since: 1,
|
||||
until: 2,
|
||||
availableSince: 3,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("five responses for one window render as five ready panels", async () => {
|
||||
renderProbe();
|
||||
await waitFor(() => expect(line("totals")).toContain("ready"));
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
expect(line(endpoint)).toBe(`${endpoint}:ready:24h@${UNTIL}/${SINCE}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("one endpoint behind a bucket boundary is refetched once and then agrees", async () => {
|
||||
// Behind on its first answer, caught up by the time the hook asks again.
|
||||
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
|
||||
catchUp.add("routes");
|
||||
renderProbe();
|
||||
|
||||
await waitFor(() => expect(line("routes")).toContain("ready"));
|
||||
expect(calls.routes).toBe(2);
|
||||
expect(calls.totals).toBe(1);
|
||||
});
|
||||
|
||||
test("a laggard that stays behind fails its own panel and leaves the rest rendering", async () => {
|
||||
bounds.types = { until: UNTIL - 3600, availableSince: SINCE };
|
||||
renderProbe();
|
||||
|
||||
await waitFor(() => expect(line("types")).toContain("error"));
|
||||
expect(line("types")).toContain("different window");
|
||||
// One retry, not a loop.
|
||||
expect(calls.types).toBe(2);
|
||||
for (const endpoint of ["totals", "timeseries", "clients", "routes"] as const) {
|
||||
expect(line(endpoint)).toContain("ready");
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed request degrades its own panel; the charts keep the window", async () => {
|
||||
failing.add("routes");
|
||||
renderProbe();
|
||||
|
||||
await waitFor(() => expect(line("routes")).toContain("error"));
|
||||
expect(line("routes")).toContain("endpoint unavailable");
|
||||
expect(line("timeseries")).toContain("ready");
|
||||
expect(line("totals")).toContain("ready");
|
||||
});
|
||||
|
||||
test("a watermark that advanced mid-page is a mismatch, not a mixed window", async () => {
|
||||
// Same bounds, later watermark: retention pruned between the two responses.
|
||||
bounds.clients = { until: UNTIL, availableSince: SINCE + 600 };
|
||||
renderProbe();
|
||||
|
||||
await waitFor(() => expect(line("clients")).toContain(`/${SINCE + 600}`));
|
||||
// The page adopts the later watermark, so the four older answers are the
|
||||
// laggards and each gets its one retry rather than rendering beside it.
|
||||
await waitFor(() => expect(calls.totals).toBe(2));
|
||||
expect(line("clients")).toContain("ready");
|
||||
});
|
||||
|
||||
test("a retained previous-period body never renders under the new period's label", async () => {
|
||||
const { rerenderWith } = renderProbe("24h");
|
||||
await waitFor(() => expect(line("totals")).toBe(`totals:ready:24h@${UNTIL}/${SINCE}`));
|
||||
|
||||
rerenderWith("1h");
|
||||
// Whatever `keepPreviousData` is holding, no panel may claim it answers 1h.
|
||||
await waitFor(() => expect(line("totals")).toBe(`totals:ready:1h@${UNTIL}/${SINCE}`));
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) expect(line(endpoint)).toContain("1h@");
|
||||
});
|
||||
|
||||
test("a period change buys the new window its own retry", async () => {
|
||||
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
|
||||
const { rerenderWith } = renderProbe("24h");
|
||||
await waitFor(() => expect(line("routes")).toContain("error"));
|
||||
const spent = calls.routes;
|
||||
|
||||
rerenderWith("1h");
|
||||
// The mismatch persists under the new period, and the episode key changed
|
||||
// with it: the retry the abandoned period spent is not the new one's.
|
||||
await waitFor(() => expect(calls.routes).toBeGreaterThan(spent));
|
||||
await waitFor(() => expect(line("routes")).toContain("error"));
|
||||
});
|
||||
|
||||
test("a retry in flight when the period changes cannot spend the window's retry later", async () => {
|
||||
// The stale completion the tokens exist to orphan: routes lags under 24h, the
|
||||
// hook issues its one retry, and the reader picks 1h before that retry lands.
|
||||
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
|
||||
let release = () => {};
|
||||
hold = { promise: new Promise<void>((resolve) => (release = resolve)), release: () => release() };
|
||||
|
||||
const { rerenderWith } = renderProbe("24h");
|
||||
await waitFor(() => expect(calls.routes).toBe(2));
|
||||
|
||||
bounds.routes = { until: UNTIL, availableSince: SINCE };
|
||||
rerenderWith("1h");
|
||||
await waitFor(() => expect(line("routes")).toContain("1h@"));
|
||||
|
||||
// The abandoned retry lands now, under a period it was never asked for.
|
||||
hold.release();
|
||||
hold = null;
|
||||
await waitFor(() => expect(line("routes")).toContain("ready"));
|
||||
|
||||
// Back to the window it was issued for, still lagging. The stale completion
|
||||
// must not have marked this episode spent: the panel gets a real retry before
|
||||
// it is allowed to reach the terminal error.
|
||||
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
|
||||
rerenderWith("24h");
|
||||
|
||||
// The cached lagging body is there to render immediately, and the panel must
|
||||
// not state the terminal error off it: that error means "retried and still
|
||||
// behind", and this visit has not retried anything yet. An abandoned
|
||||
// completion recording the episode as spent is what would produce it here.
|
||||
expect(line("routes")).toContain("loading");
|
||||
await waitFor(() => expect(line("routes")).toContain("different window"));
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* One period, five requests, one window.
|
||||
*
|
||||
* Totals, the timeline, the per-client series and the two breakdowns are
|
||||
* separate calls, so a refresh that straddles a bucket boundary — or a retention
|
||||
* pass that advances the watermark mid-page — can answer them for different
|
||||
* windows. Rendering them side by side anyway would put a headline count above
|
||||
* charts of a different span, a mixed page that looks exactly like a real one.
|
||||
*
|
||||
* This is **window** coherence, not data-snapshot coherence: matching bounds
|
||||
* cannot prove a common database state, and live inserts between requests may
|
||||
* still shift counts slightly between panels. What it does guarantee is that no
|
||||
* two panels ever describe different spans.
|
||||
*
|
||||
* Rendering is per panel. A panel whose request is still in flight shows its own
|
||||
* loading state and a panel whose request failed shows its own error, while the
|
||||
* panels that match the window keep rendering — a failed donut never blanks the
|
||||
* charts.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { statsClientsQuery, statsQuery, statsRoutesQuery, statsTypesQuery, timeseriesQuery } from "@/lib/queries";
|
||||
import type {
|
||||
Coverage,
|
||||
Period,
|
||||
StatsClients,
|
||||
StatsRoutes,
|
||||
StatsTimeseries,
|
||||
StatsTotals,
|
||||
StatsTypes,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const OVERVIEW_ENDPOINTS = ["totals", "timeseries", "clients", "types", "routes"] as const;
|
||||
export type OverviewEndpoint = (typeof OVERVIEW_ENDPOINTS)[number];
|
||||
|
||||
interface EndpointBodies {
|
||||
totals: StatsTotals;
|
||||
timeseries: StatsTimeseries;
|
||||
clients: StatsClients;
|
||||
types: StatsTypes;
|
||||
routes: StatsRoutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* What makes two responses the same window. `available_since` joins the bounds
|
||||
* because retention advancing between requests changes what the same `[since,
|
||||
* until)` can answer for, and mixing a pre-prune answer with a post-prune one is
|
||||
* the failure the bounds alone would not catch.
|
||||
*/
|
||||
export interface WindowId {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
availableSince: number;
|
||||
}
|
||||
|
||||
/** The four fields every window-bounded stats body carries. */
|
||||
interface Bounded {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
coverage: Coverage;
|
||||
}
|
||||
|
||||
export function windowIdOf(body: Bounded): WindowId {
|
||||
return {
|
||||
period: body.period,
|
||||
since: body.since,
|
||||
until: body.until,
|
||||
availableSince: body.coverage.available_since,
|
||||
};
|
||||
}
|
||||
|
||||
export function sameWindow(a: WindowId, b: WindowId): boolean {
|
||||
return a.period === b.period && a.since === b.since && a.until === b.until && a.availableSince === b.availableSince;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of two candidate windows the page adopts: the one that reaches further
|
||||
* forward in time, and for identical bounds the one that admits the later
|
||||
* watermark. Both rules pick the answer a laggard has to catch up to.
|
||||
*/
|
||||
export function newerWindow(a: WindowId, b: WindowId): WindowId {
|
||||
if (b.until !== a.until) return b.until > a.until ? b : a;
|
||||
return b.availableSince > a.availableSince ? b : a;
|
||||
}
|
||||
|
||||
function keyOf(id: WindowId): string {
|
||||
return `${id.period}|${id.since}|${id.until}|${id.availableSince}`;
|
||||
}
|
||||
|
||||
export type Panel<T> =
|
||||
{ status: "loading" } | { status: "error"; error: unknown; retry: () => void } | { status: "ready"; data: T };
|
||||
|
||||
export interface OverviewWindow {
|
||||
/** Null until one response for the selected period has arrived. */
|
||||
window: WindowId | null;
|
||||
/** The adopted window's watermark, for the page's single coverage notice. */
|
||||
coverage: Coverage | null;
|
||||
totals: Panel<StatsTotals>;
|
||||
timeseries: Panel<StatsTimeseries>;
|
||||
clients: Panel<StatsClients>;
|
||||
types: Panel<StatsTypes>;
|
||||
routes: Panel<StatsRoutes>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A laggard that stayed behind after its one retry. Not an `ApiError`: nothing
|
||||
* failed, the endpoint simply never caught up, and `InlineError` renders the
|
||||
* message verbatim.
|
||||
*/
|
||||
export const MISMATCH = new Error("This panel is for a different window than the rest of the page. Try again.");
|
||||
|
||||
export function useOverviewWindow(period: Period): OverviewWindow {
|
||||
const queries: { [K in OverviewEndpoint]: UseQueryResult<EndpointBodies[K]> } = {
|
||||
totals: useQuery({ ...statsQuery(period), placeholderData: keepPreviousData }),
|
||||
timeseries: useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData }),
|
||||
clients: useQuery({ ...statsClientsQuery(period), placeholderData: keepPreviousData }),
|
||||
types: useQuery({ ...statsTypesQuery(period), placeholderData: keepPreviousData }),
|
||||
routes: useQuery({ ...statsRoutesQuery(period), placeholderData: keepPreviousData }),
|
||||
};
|
||||
|
||||
// A `keepPreviousData` placeholder for the period just left is a complete,
|
||||
// self-consistent body — and still the wrong one to show under the new label,
|
||||
// so it is neither a candidate for the window nor a member of it.
|
||||
const answers = new Map<OverviewEndpoint, WindowId>();
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
const data = queries[endpoint].data;
|
||||
if (data !== undefined && data.period === period) answers.set(endpoint, windowIdOf(data));
|
||||
}
|
||||
|
||||
let window: WindowId | null = null;
|
||||
for (const id of answers.values()) window = window === null ? id : newerWindow(window, id);
|
||||
|
||||
// The effect below runs on what the responses say, not on how many times they
|
||||
// arrived: a poll that returns byte-identical data must not restart the retry
|
||||
// bookkeeping. The refetchers ride a ref for the same reason — TanStack hands
|
||||
// back a fresh function identity on some renders, and depending on it would
|
||||
// re-enter the effect with nothing changed.
|
||||
const answersKey = OVERVIEW_ENDPOINTS.map((endpoint) => {
|
||||
const id = answers.get(endpoint);
|
||||
return id === undefined ? "" : keyOf(id);
|
||||
}).join("~");
|
||||
const latest = useRef({ answers, refetch: queries });
|
||||
latest.current = { answers, refetch: queries };
|
||||
|
||||
// Which mismatch episode each endpoint has already spent its retry on, keyed
|
||||
// by endpoint and window identity so a new window buys a new attempt.
|
||||
const retriedFor = useRef(new Map<OverviewEndpoint, string>());
|
||||
// Which retry each endpoint is waiting on. Per endpoint, because one shared
|
||||
// counter would let a second endpoint's retry silence the first's completion;
|
||||
// bumped on every retry issued, so a completion from a window or a period the
|
||||
// page has left can neither clear an error the current one reached nor spend
|
||||
// the current window's one retry.
|
||||
const tokens = useRef(new Map<OverviewEndpoint, number>());
|
||||
// State, not a ref: a retry that returns byte-identical data changes nothing
|
||||
// else a render could see, and the panel still has to reach its error.
|
||||
const [landedFor, setLandedFor] = useState(new Map<OverviewEndpoint, string>());
|
||||
|
||||
// Leaving a period ends every episode it opened. A retry issued for the old
|
||||
// period can still be in flight, and without this its completion would land
|
||||
// under the new one holding a token the map still honours: it would record an
|
||||
// episode as spent, so a return to that window would reach the terminal error
|
||||
// without the retry that error is supposed to follow. Bumping the tokens
|
||||
// orphans those answers, and the cleared maps let the new window start clean.
|
||||
const [lastPeriod, setLastPeriod] = useState(period);
|
||||
if (lastPeriod !== period) {
|
||||
setLastPeriod(period);
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
|
||||
}
|
||||
retriedFor.current.clear();
|
||||
setLandedFor(new Map());
|
||||
}
|
||||
|
||||
const windowKey = window === null ? null : keyOf(window);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowKey === null) return;
|
||||
for (const [endpoint, identity] of latest.current.answers) {
|
||||
if (keyOf(identity) === windowKey) {
|
||||
retriedFor.current.delete(endpoint);
|
||||
continue;
|
||||
}
|
||||
const episode = `${endpoint}|${windowKey}`;
|
||||
if (retriedFor.current.get(endpoint) === episode) continue;
|
||||
retriedFor.current.set(endpoint, episode);
|
||||
const token = (tokens.current.get(endpoint) ?? 0) + 1;
|
||||
tokens.current.set(endpoint, token);
|
||||
const landed = () => {
|
||||
if (tokens.current.get(endpoint) !== token) return;
|
||||
setLandedFor((previous) => new Map(previous).set(endpoint, episode));
|
||||
};
|
||||
void latest.current.refetch[endpoint].refetch().then(landed, landed);
|
||||
}
|
||||
}, [answersKey, windowKey]);
|
||||
|
||||
const retry = useCallback((endpoint: OverviewEndpoint) => {
|
||||
retriedFor.current.delete(endpoint);
|
||||
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
|
||||
setLandedFor((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.delete(endpoint);
|
||||
return next;
|
||||
});
|
||||
void latest.current.refetch[endpoint].refetch();
|
||||
}, []);
|
||||
|
||||
function panelOf<K extends OverviewEndpoint>(endpoint: K): Panel<EndpointBodies[K]> {
|
||||
const query = queries[endpoint];
|
||||
const onRetry = () => retry(endpoint);
|
||||
if (query.isError) return { status: "error", error: query.error, retry: onRetry };
|
||||
const data = query.data;
|
||||
if (
|
||||
data !== undefined &&
|
||||
windowKey !== null &&
|
||||
data.period === period &&
|
||||
keyOf(windowIdOf(data)) === windowKey
|
||||
) {
|
||||
return { status: "ready", data };
|
||||
}
|
||||
if (windowKey !== null && landedFor.get(endpoint) === `${endpoint}|${windowKey}`) {
|
||||
return { status: "error", error: MISMATCH, retry: onRetry };
|
||||
}
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
const panels = {
|
||||
totals: panelOf("totals"),
|
||||
timeseries: panelOf("timeseries"),
|
||||
clients: panelOf("clients"),
|
||||
types: panelOf("types"),
|
||||
routes: panelOf("routes"),
|
||||
};
|
||||
|
||||
// The notice describes the window, so any member of it can supply the
|
||||
// watermark: whichever panel arrived says the same thing about coverage.
|
||||
let coverage: Coverage | null = null;
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
const panel = panels[endpoint];
|
||||
if (panel.status === "ready") {
|
||||
coverage = panel.data.coverage;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { window, coverage, ...panels };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The period Overview is scoped to, as URL state.
|
||||
*
|
||||
* One home for the four values and for the rule that turns whatever the URL
|
||||
* carried into one of them: the route validates with it and the picker offers
|
||||
* exactly the same list, so a hand-typed `?period=90d` becomes the default
|
||||
* instead of reaching the API as a parameter it answers 400 to.
|
||||
*/
|
||||
|
||||
import type { Period } from "@/lib/types";
|
||||
|
||||
export const PERIODS = ["1h", "24h", "7d", "30d"] as const satisfies readonly Period[];
|
||||
|
||||
export const DEFAULT_PERIOD: Period = "24h";
|
||||
|
||||
/**
|
||||
* Undefined rather than the default for anything that is not one of the four,
|
||||
* so an absent parameter and a nonsense one both leave a clean URL. The default
|
||||
* is applied where the period is read, not written back into the address bar.
|
||||
*/
|
||||
export function parsePeriod(value: unknown): Period | undefined {
|
||||
return PERIODS.find((period) => period === value);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { OTHER_KEY, clientKey, qtypeKey, routeKey, seriesColor } from "./seriesColors";
|
||||
|
||||
test("the four source-less route kinds and other are fixed, so they mean one thing everywhere", () => {
|
||||
expect(seriesColor(routeKey("blocked", null))).toBe("#ef4444");
|
||||
expect(seriesColor(routeKey("cache", null))).toBe("#059669");
|
||||
expect(seriesColor(routeKey("local", null))).toBe("#8b5cf6");
|
||||
expect(seriesColor(routeKey("rejected", null))).toBe("#f59e0b");
|
||||
expect(seriesColor(OTHER_KEY)).toBe("#71717a");
|
||||
});
|
||||
|
||||
test("the colour of a key depends on the key and on nothing else", () => {
|
||||
// Rank churn and membership churn at once: the panel a poll later is in the
|
||||
// opposite order, has gained a client and has lost one. Every entry that
|
||||
// survived keeps its colour, because nothing here reads the set.
|
||||
const survivors = [clientKey("192.0.2.30"), clientKey("192.0.2.31"), clientKey("192.0.2.32"), OTHER_KEY];
|
||||
const before = survivors.map(seriesColor);
|
||||
const after = [clientKey("192.0.2.10"), ...survivors].reverse().map(seriesColor);
|
||||
for (const [i, key] of survivors.entries()) {
|
||||
expect(seriesColor(key)).toBe(before[i]);
|
||||
expect(after).toContain(before[i]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a panel of realistic entries gets a spread of hues, not one colour repeated", () => {
|
||||
// The degenerate implementation this refutes: a dynamic branch that returns
|
||||
// one constant would satisfy every stability test in this file. It also states
|
||||
// the real cost of hashing without assignment — eight clients come out in five
|
||||
// hues here, seven query types in four — which is why the donut strokes its
|
||||
// arcs and the client chart strokes its segments.
|
||||
const clients = [
|
||||
"192.0.2.30",
|
||||
"192.0.2.31",
|
||||
"192.0.2.32",
|
||||
"192.0.2.40",
|
||||
"10.0.0.5",
|
||||
"10.0.0.6",
|
||||
"fd00::1",
|
||||
"laptop.lan",
|
||||
];
|
||||
const types = [1, 28, 65, 12, 16, 33];
|
||||
const routes = ["https://dns.example/dns-query", "https://dns2.example/dns-query", "lan"];
|
||||
|
||||
const spreadOf = (keys: string[]) => new Set(keys.map(seriesColor)).size;
|
||||
expect(spreadOf(clients.map(clientKey))).toBeGreaterThan(1);
|
||||
expect(spreadOf([...types.map(qtypeKey), qtypeKey(null)])).toBeGreaterThan(1);
|
||||
expect(spreadOf(routes.map((source) => routeKey("upstream", source)))).toBeGreaterThan(1);
|
||||
// Half the panel distinct at worst, which is what makes the legend readable
|
||||
// rather than a list of identical swatches.
|
||||
expect(spreadOf(clients.map(clientKey))).toBeGreaterThanOrEqual(clients.length / 2);
|
||||
});
|
||||
|
||||
test("a dynamic entry never takes a fixed entry's colour", () => {
|
||||
// The bug this rules out: a nameless upstream row coming out the same red as
|
||||
// the Blocked slice beside it in the same ring.
|
||||
const fixedColors = new Set(["#ef4444", "#059669", "#8b5cf6", "#f59e0b", "#71717a"]);
|
||||
const keys = [routeKey("upstream", null), routeKey("forward_zone", "lan"), qtypeKey(28), qtypeKey(null)];
|
||||
for (const key of keys) expect(fixedColors.has(seriesColor(key))).toBe(false);
|
||||
});
|
||||
|
||||
test("the same name under two route kinds is two identities", () => {
|
||||
expect(routeKey("upstream", "lan")).not.toBe(routeKey("forward_zone", "lan"));
|
||||
});
|
||||
|
||||
test("two upstreams are two identities: the pair, not the route kind, is the key", () => {
|
||||
expect(routeKey("upstream", "https://dns.example/dns-query")).not.toBe(
|
||||
routeKey("upstream", "https://dns2.example/dns-query"),
|
||||
);
|
||||
});
|
||||
|
||||
test("a null qtype is its own entry rather than folded into a real one", () => {
|
||||
expect(qtypeKey(null)).not.toBe(qtypeKey(1));
|
||||
expect(seriesColor(qtypeKey(null))).toBe(seriesColor(qtypeKey(null)));
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* A colour per thing, not per position.
|
||||
*
|
||||
* Every series and slice on Overview is ranked by count, and a rank that changes
|
||||
* between two thirty-second polls would recolour the whole panel if colour came
|
||||
* from the ordinal. So colour keys on the entry's semantic identity: the qtype
|
||||
* value, the client string, or — for routes — the full `(route, source)` pair,
|
||||
* because keying on the route kind alone would paint two adjacent upstream
|
||||
* slices the same and merge them into one shape.
|
||||
*
|
||||
* The four source-less route kinds and the "other" bucket are fixed rather than
|
||||
* hashed: they mean the same thing on every install, and Blocked and Cache
|
||||
* already have colours on the query-volume timeline.
|
||||
*/
|
||||
|
||||
import type { RouteKind } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The dynamic hues, validated for CVD separation and 3:1 contrast against both
|
||||
* surfaces; the same hex in light and dark, as the timeline's series are. The
|
||||
* five fixed colours below are deliberately not in here: a nameless upstream row
|
||||
* must not come out the same red as Blocked in the ring beside it.
|
||||
*/
|
||||
const PALETTE = ["#3b82f6", "#ec4899", "#14b8a6", "#f97316", "#6366f1", "#84cc16", "#06b6d4", "#a855f7"] as const;
|
||||
|
||||
const FIXED: Record<string, string> = {
|
||||
"route:blocked": "#ef4444",
|
||||
"route:cache": "#059669",
|
||||
"route:local": "#8b5cf6",
|
||||
"route:rejected": "#f59e0b",
|
||||
other: "#71717a",
|
||||
};
|
||||
|
||||
/** The identity of everything outside the top eight clients. */
|
||||
export const OTHER_KEY = "other";
|
||||
|
||||
export function qtypeKey(qtype: number | null): string {
|
||||
return qtype === null ? "qtype:none" : `qtype:${qtype}`;
|
||||
}
|
||||
|
||||
export function clientKey(client: string): string {
|
||||
return `client:${client}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream and forward-zone rows are identified by their source as well as their
|
||||
* kind; the other four kinds have no source and collapse to the kind alone, so
|
||||
* they land on their fixed colour.
|
||||
*/
|
||||
export function routeKey(route: RouteKind, source: string | null): string {
|
||||
return source === null ? `route:${route}` : `route:${route}:${source}`;
|
||||
}
|
||||
|
||||
/** FNV-1a, 32-bit: stable across reloads and across browsers, which is the whole point. */
|
||||
function hash(key: string): number {
|
||||
let value = 0x811c9dc5;
|
||||
for (let i = 0; i < key.length; i += 1) {
|
||||
value ^= key.charCodeAt(i);
|
||||
value = Math.imul(value, 0x01000193);
|
||||
}
|
||||
return value >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour of one key, and of nothing else.
|
||||
*
|
||||
* This is a pure function of the identity: no panel, no key set, no rank. That
|
||||
* is the property the page needs, because the panels churn — a client enters the
|
||||
* top eight and another leaves it every few polls — and an assignment that read
|
||||
* the whole set would repaint entries that did not change at all.
|
||||
*
|
||||
* The cost is that a hash is not injective: two entries of one panel can come
|
||||
* out the same hue. That is a real cost and it is the smaller one. Resolving it
|
||||
* by probing would mean the entries that lost a slot depend on which entries
|
||||
* were present, which is the churn this exists to prevent — and eight hues
|
||||
* cannot colour nine things distinctly in any case. The failure a shared hue
|
||||
* would cause instead, two neighbouring slices merging into one shape, is
|
||||
* prevented where it happens: the donut strokes every arc and the client chart
|
||||
* strokes every segment in the surface colour, so equal hues still read as two.
|
||||
* The legend and the hidden table name every entry either way.
|
||||
*/
|
||||
export function seriesColor(key: string): string {
|
||||
return FIXED[key] ?? PALETTE[hash(key) % PALETTE.length];
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* The Pause control, migrated from the header PauseWidget it replaces. Every
|
||||
* behaviour that widget pinned is pinned here, now driven by `Health.protection`
|
||||
* rather than by a second poll of `/api/pause`.
|
||||
*/
|
||||
|
||||
import { act } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import PauseControl from "@/features/pause/PauseControl";
|
||||
import { formatClock } from "@/lib/format";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { Health, PausePost, PauseState } from "@/lib/types";
|
||||
|
||||
let protection: Health["protection"];
|
||||
let postBodies: PausePost[];
|
||||
let postFailure: (() => Response) | null;
|
||||
let healthFails: boolean;
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
protection = { state: "active", until: null };
|
||||
postBodies = [];
|
||||
postFailure = null;
|
||||
healthFails = false;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/health") {
|
||||
if (healthFails) {
|
||||
return new Response(JSON.stringify({ error: "nope" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return jsonResponse(health({ protection }));
|
||||
}
|
||||
if (url === "/api/pause" && init?.method === "POST") {
|
||||
const body = JSON.parse(String(init.body)) as PausePost;
|
||||
postBodies.push(body);
|
||||
if (postFailure !== null) return postFailure();
|
||||
protection = body.paused
|
||||
? {
|
||||
state: "paused",
|
||||
until:
|
||||
body.duration_seconds == null
|
||||
? null
|
||||
: Math.floor(Date.now() / 1000) + body.duration_seconds,
|
||||
}
|
||||
: { state: "active", until: null };
|
||||
const echo: PauseState = { paused: body.paused, until: protection.until };
|
||||
return jsonResponse(echo);
|
||||
}
|
||||
return jsonResponse({ error: "not stubbed" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderControl(client?: QueryClient) {
|
||||
render(
|
||||
<QueryClientProvider client={client ?? createQueryClient()}>
|
||||
<PauseControl />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function findPauseTrigger(): Promise<HTMLButtonElement> {
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(false);
|
||||
});
|
||||
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
|
||||
renderControl();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
|
||||
expect(screen.getByRole("button", { name: label })).toBeTruthy();
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
});
|
||||
|
||||
test("indefinite pause sends no duration_seconds", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
});
|
||||
|
||||
test("resume posts paused false and returns to the Pause button", async () => {
|
||||
protection = { state: "paused", until: null };
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
|
||||
await screen.findByRole("button", { name: "Pause" });
|
||||
});
|
||||
|
||||
test("a timed pause says until when, beside the control that would end it", () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const client = createQueryClient();
|
||||
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: nowSec + 90 } }));
|
||||
renderControl(client);
|
||||
|
||||
// The same clock format the Diagnostics health strip writes, off the same
|
||||
// reading, so the two cannot say different things about one pause.
|
||||
expect(screen.getByText(`Paused until ${formatClock(nowSec + 90)}`)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a pause with no end says so without inventing a time", () => {
|
||||
const client = createQueryClient();
|
||||
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: null } }));
|
||||
renderControl(client);
|
||||
|
||||
expect(screen.getByText("Paused")).toBeTruthy();
|
||||
expect(screen.queryByText(/until/)).toBeNull();
|
||||
});
|
||||
|
||||
test("an active resolver states nothing: the button already says Pause", async () => {
|
||||
renderControl();
|
||||
|
||||
expect(await findPauseTrigger()).toBeTruthy();
|
||||
expect(screen.queryByText(/^Paused/)).toBeNull();
|
||||
});
|
||||
|
||||
test("escape closes the duration menu", async () => {
|
||||
renderControl();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
fireEvent.keyDown(trigger, { key: "Escape" });
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
});
|
||||
|
||||
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "Retry-After": "30" },
|
||||
});
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
|
||||
});
|
||||
|
||||
test("failed pause with 503 shows the degraded message", async () => {
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||
});
|
||||
|
||||
test("a successful pause clears the previous mutation error", async () => {
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await screen.findByRole("alert");
|
||||
|
||||
postFailure = null;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("no control at all while protection is unavailable", async () => {
|
||||
protection = { state: "unavailable", until: null };
|
||||
const client = createQueryClient();
|
||||
renderControl(client);
|
||||
|
||||
await waitFor(() => expect(client.getQueryData(queryKeys.health)).toBeDefined());
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
test("a failed poll after a good one withdraws the control rather than acting on a stale state", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
await vi.waitFor(() =>
|
||||
expect((screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement).disabled).toBe(false),
|
||||
);
|
||||
|
||||
healthFails = true;
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
|
||||
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("a menu left open when the control withdraws does not come back open", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
protection = { state: "unavailable", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
|
||||
|
||||
protection = { state: "active", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
|
||||
// Protection is back, and so is the trigger — but the menu is a thing the
|
||||
// reader opened, and nobody opened this one.
|
||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("a menu open when someone else pauses does not reopen when that pause ends", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
// Filtering is paused from somewhere else, and this browser learns it from
|
||||
// the poll. The Resume rendering has no menu.
|
||||
protection = { state: "paused", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy());
|
||||
|
||||
protection = { state: "active", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
|
||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("protection unknown offers no control at all, by the same rule as unavailable", () => {
|
||||
// Health has not answered. Which of Pause and Resume applies is exactly what
|
||||
// it has not said, so the control names neither.
|
||||
renderControl();
|
||||
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* The Pause/Resume control, in the two places a pause is a valid answer to what
|
||||
* the reader is looking at: the foot of the sidebar, where it belongs to the
|
||||
* resolver rather than to any page, and beside the detail of a query that was
|
||||
* blocked.
|
||||
*
|
||||
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
|
||||
* the Diagnostics health strip, and it renders nothing at all while protection
|
||||
* is unavailable or unknown — pausing a resolver that has no filter snapshot
|
||||
* would change nothing an operator could observe, and a state health has not
|
||||
* confirmed does not name an action either.
|
||||
*
|
||||
* A pause says so, wherever the control is. "Resume" alone names an action
|
||||
* without stating the state it would end, and with the header indicator and the
|
||||
* Overview status row both gone the sidebar is the only place most pages can
|
||||
* carry that fact at all: a paused resolver would otherwise leave no trace
|
||||
* outside the Diagnostics page. The line and the health strip cannot disagree —
|
||||
* one `protection` reading, one clock format, and the expiry refetch in
|
||||
* `useProtection` retires both at the same moment. An active resolver gets no
|
||||
* line: the button says Pause, which is the whole message.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatClock } from "@/lib/format";
|
||||
import { pauseMutation } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { useProtection } from "./protection";
|
||||
|
||||
const DURATIONS = [
|
||||
{ label: "60 seconds", seconds: 60 },
|
||||
{ label: "5 minutes", seconds: 300 },
|
||||
{ label: "30 minutes", seconds: 1800 },
|
||||
{ label: "Indefinitely", seconds: null },
|
||||
] as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* Disabled text darkens in light scheme and lightens in dark, the opposite
|
||||
* direction from `textMuted`, so the token cannot express it.
|
||||
*/
|
||||
trigger: {
|
||||
color: {
|
||||
default: null,
|
||||
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||
},
|
||||
},
|
||||
row: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
/** Text, never a colour or an icon alone: this is the state, spelled out. */
|
||||
state: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
anchor: {
|
||||
position: "relative",
|
||||
},
|
||||
menu: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: "100%",
|
||||
zIndex: 10,
|
||||
marginTop: "0.25rem",
|
||||
display: "flex",
|
||||
width: "9rem",
|
||||
flexDirection: "column",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingBlock: "0.25rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menuItem: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: "inherit",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function PauseControl() {
|
||||
const queryClient = useQueryClient();
|
||||
const protection = useProtection();
|
||||
const mutation = useMutation(pauseMutation(queryClient));
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const paused = protection.state === "paused";
|
||||
const { reset } = mutation;
|
||||
useEffect(() => reset(), [paused, reset]);
|
||||
|
||||
// Nothing to offer, by the same rule in both cases: unavailable has no action
|
||||
// worth taking, and unknown has no way to tell which of the two it would be.
|
||||
// A disabled "Pause" beside a reading that says "Paused until 14:05" names
|
||||
// the wrong action, which is the contradiction this control exists to end.
|
||||
const actionable = protection.state === "active" || protection.state === "paused";
|
||||
|
||||
// Leaving `active` unmounts the menu but not the state that opened it, and
|
||||
// the menu belongs to the active rendering alone — a pause someone else
|
||||
// started, seen through the poll, takes it away exactly as a withdrawal does.
|
||||
// Closing on the way out rather than on the way back means the trigger can
|
||||
// only ever come back shut, however long it was gone.
|
||||
useEffect(() => {
|
||||
if (protection.state !== "active") setMenuOpen(false);
|
||||
}, [protection.state]);
|
||||
|
||||
if (!actionable) return null;
|
||||
|
||||
if (paused) {
|
||||
const until = protection.state === "paused" ? protection.until : null;
|
||||
return (
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<p {...stylex.props(styles.state)}>
|
||||
{until === null ? "Paused" : `Paused until ${formatClock(until)}`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mutation.mutate({ paused: false })}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...stylex.props(styles.anchor)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="pause-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
mutation.mutate(
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { act } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import PauseWidget, { formatRemaining } from "@/features/pause/PauseWidget";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { PausePost, PauseState } from "@/lib/types";
|
||||
|
||||
let getState: PauseState;
|
||||
let postBodies: PausePost[];
|
||||
let postResponse: (body: PausePost) => PauseState;
|
||||
let postFailure: (() => Response) | null;
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
postBodies = [];
|
||||
postFailure = null;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url !== "/api/pause") return jsonResponse({ error: "not stubbed" });
|
||||
if (init?.method === "POST") {
|
||||
const body = JSON.parse(String(init.body)) as PausePost;
|
||||
postBodies.push(body);
|
||||
if (postFailure !== null) return postFailure();
|
||||
return jsonResponse(postResponse(body));
|
||||
}
|
||||
return jsonResponse(getState);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderWidget(client?: QueryClient) {
|
||||
render(
|
||||
<QueryClientProvider client={client ?? createQueryClient()}>
|
||||
<PauseWidget />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function findPauseTrigger(): Promise<HTMLButtonElement> {
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(false);
|
||||
});
|
||||
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
|
||||
renderWidget();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
|
||||
expect(screen.getByRole("button", { name: label })).toBeTruthy();
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.getByText(/^Paused \d+:\d{2}$/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("indefinite pause sends no duration_seconds and renders without a countdown", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postResponse = () => ({ paused: true, until: null });
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.getByText("Paused")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resume posts paused false and returns to the Pause button", async () => {
|
||||
getState = { paused: true, until: null };
|
||||
postResponse = () => ({ paused: false, until: null });
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
|
||||
await screen.findByRole("button", { name: "Pause" });
|
||||
});
|
||||
|
||||
test("timed pause counts down live", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const client = createQueryClient();
|
||||
client.setQueryData(queryKeys.pause, { paused: true, until: nowSec + 90 });
|
||||
renderWidget(client);
|
||||
|
||||
expect(screen.getByText("Paused 1:30")).toBeTruthy();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(screen.getByText("Paused 1:28")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("escape closes the duration menu", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postResponse = () => getState;
|
||||
renderWidget();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
fireEvent.keyDown(trigger, { key: "Escape" });
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
});
|
||||
|
||||
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
getState = { paused: false, until: null };
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "Retry-After": "30" },
|
||||
});
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
|
||||
});
|
||||
|
||||
test("failed pause with 503 shows the degraded message", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||
});
|
||||
|
||||
test("a successful pause clears the previous mutation error", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await screen.findByRole("alert");
|
||||
|
||||
postFailure = null;
|
||||
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("formatRemaining renders m:ss and h:mm:ss and clamps at zero", () => {
|
||||
expect(formatRemaining(0)).toBe("0:00");
|
||||
expect(formatRemaining(-5)).toBe("0:00");
|
||||
expect(formatRemaining(59)).toBe("0:59");
|
||||
expect(formatRemaining(90)).toBe("1:30");
|
||||
expect(formatRemaining(3661)).toBe("1:01:01");
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { pauseMutation, pauseQuery } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DURATIONS = [
|
||||
{ label: "60 seconds", seconds: 60 },
|
||||
{ label: "5 minutes", seconds: 300 },
|
||||
{ label: "30 minutes", seconds: 1800 },
|
||||
{ label: "Indefinitely", seconds: null },
|
||||
] as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* Disabled text darkens in light scheme and lightens in dark, the opposite
|
||||
* direction from `textMuted`, so the token cannot express it.
|
||||
*/
|
||||
trigger: {
|
||||
color: {
|
||||
default: null,
|
||||
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||
},
|
||||
},
|
||||
pausedRow: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
pausedControls: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
/**
|
||||
* Amber as standalone text on the app ground, not inside a warning banner, so
|
||||
* the `warn*` tokens — tuned against `warnSurface` — do not apply here.
|
||||
*/
|
||||
pausedLabel: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: {
|
||||
default: "oklch(55.5% 0.163 48.998)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
|
||||
},
|
||||
},
|
||||
anchor: {
|
||||
position: "relative",
|
||||
},
|
||||
menu: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: "100%",
|
||||
zIndex: 10,
|
||||
marginTop: "0.25rem",
|
||||
display: "flex",
|
||||
width: "9rem",
|
||||
flexDirection: "column",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingBlock: "0.25rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menuItem: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: "inherit",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
});
|
||||
|
||||
export function formatRemaining(totalSeconds: number): string {
|
||||
const clamped = Math.max(0, totalSeconds);
|
||||
const hours = Math.floor(clamped / 3600);
|
||||
const minutes = Math.floor((clamped % 3600) / 60);
|
||||
const seconds = clamped % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function useNowSeconds(active: boolean): number {
|
||||
const [now, setNow] = useState(nowSeconds);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setNow(nowSeconds());
|
||||
const id = setInterval(() => setNow(nowSeconds()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export default function PauseWidget() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useQuery({
|
||||
...pauseQuery(),
|
||||
refetchInterval: (query) => (query.state.data?.paused === true ? 5000 : false),
|
||||
});
|
||||
const mutation = useMutation(pauseMutation(queryClient));
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const now = useNowSeconds(data?.paused === true && data.until !== null);
|
||||
const paused = data?.paused === true;
|
||||
const { reset } = mutation;
|
||||
useEffect(() => reset(), [paused, reset]);
|
||||
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
|
||||
Pause
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.paused) {
|
||||
return (
|
||||
<div {...stylex.props(styles.pausedRow)}>
|
||||
<div {...stylex.props(styles.pausedControls)}>
|
||||
<span {...stylex.props(styles.pausedLabel)}>
|
||||
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mutation.mutate({ paused: false })}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...stylex.props(styles.anchor)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="pause-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
mutation.mutate(
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Protection, as the interface reads it: one view model derived from
|
||||
* `Health.protection`, shared by the sidebar Pause control, the related action
|
||||
* on a blocked query's detail, and the Diagnostics health strip.
|
||||
*
|
||||
* There is deliberately no second source. `GET /api/pause` answers the same
|
||||
* question, but two polled copies of one fact can disagree, and the row that
|
||||
* says "Paused" beside a button that says "Pause" is exactly the contradiction
|
||||
* this milestone set out to remove.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { healthQuery, queryKeys } from "@/lib/queries";
|
||||
import type { Health } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* `unknown` is its own state, never folded into `active`: health has not
|
||||
* answered yet, or the last poll failed, and claiming filtering is in force on
|
||||
* no evidence is the one reading that could get a household bitten.
|
||||
*/
|
||||
export type ProtectionView =
|
||||
{ state: "unknown" } | { state: "active" } | { state: "paused"; until: number | null } | { state: "unavailable" };
|
||||
|
||||
export function protectionViewOf(protection: Health["protection"] | undefined): ProtectionView {
|
||||
if (protection === undefined) return { state: "unknown" };
|
||||
if (protection.state === "unavailable") return { state: "unavailable" };
|
||||
if (protection.state === "paused") return { state: "paused", until: protection.until };
|
||||
return { state: "active" };
|
||||
}
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Protection now, with the expiry of a timed pause scheduled.
|
||||
*
|
||||
* Health polls every ten seconds, so without the timer a pause that ended
|
||||
* three seconds ago still reads "Paused until 14:05" — a stale claim about the
|
||||
* one fact the indicator exists to state. The timeout fires a second past
|
||||
* `until` so the server's own expiry rule, not the browser's clock, decides.
|
||||
*/
|
||||
export function useProtection(): ProtectionView {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isError } = useQuery(healthQuery());
|
||||
// A failed poll leaves the last body in the cache, and that body is a
|
||||
// reading, not the current state: the query this view answers is "is
|
||||
// filtering in force right now", and a stale yes is the same lie as an
|
||||
// invented one. The Diagnostics health strip may still render the cached
|
||||
// conditions, because it marks them stale in the same breath; this view has
|
||||
// no such caption, so a failure is `unknown`.
|
||||
const view = isError ? ({ state: "unknown" } as const) : protectionViewOf(data?.protection);
|
||||
const until = view.state === "paused" ? view.until : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (until === null) return;
|
||||
const delay = Math.max(0, until - nowSeconds() + 1) * 1000;
|
||||
const timer = setTimeout(() => void queryClient.invalidateQueries({ queryKey: queryKeys.health }), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [until, queryClient]);
|
||||
|
||||
return view;
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import type { Client, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import QueryLogPage from "./QueryLogPage";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
id,
|
||||
ip,
|
||||
name,
|
||||
learned_name: learnedName,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: name !== "",
|
||||
first_seen: 1_700_000_000,
|
||||
last_seen: 1_700_000_100,
|
||||
};
|
||||
}
|
||||
|
||||
const CLIENTS: Client[] = [
|
||||
client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||
client(2, "192.0.2.11", "", "laptop.lan"),
|
||||
client(3, "192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||
return {
|
||||
id,
|
||||
ts: 1_700_000_000 + id,
|
||||
domain,
|
||||
client_ip: "192.0.2.10",
|
||||
qtype: 1,
|
||||
blocked: false,
|
||||
block_reason: "",
|
||||
response_time_us: 1234,
|
||||
cache_hit: false,
|
||||
upstream: "udp://9.9.9.9:53",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const PAGES: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [
|
||||
row(20, "first.example", { qtype: 65, cache_hit: true, upstream: "" }),
|
||||
row(19, "ads.example", {
|
||||
blocked: true,
|
||||
block_reason: "blocklist:stevenblack",
|
||||
response_time_us: null,
|
||||
cache_hit: null,
|
||||
}),
|
||||
],
|
||||
next_before: 19,
|
||||
},
|
||||
"/api/queries?before=19": {
|
||||
queries: [row(5, "older.example")],
|
||||
next_before: null,
|
||||
},
|
||||
"/api/queries?domain=ads": {
|
||||
queries: [row(19, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack" })],
|
||||
next_before: null,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<QueryLogPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
test("renders the first page with type names, blocked badge, and formatted cells", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
expect(screen.getByText("HTTPS")).toBeTruthy();
|
||||
expect(screen.getByText("A")).toBeTruthy();
|
||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
|
||||
expect(screen.getByText("1.2 ms")).toBeTruthy();
|
||||
expect(screen.getByText("hit")).toBeTruthy();
|
||||
expect(screen.getByText("udp://9.9.9.9:53")).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 () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [
|
||||
row(20, "named.example", { client_ip: "192.0.2.10" }),
|
||||
row(19, "learned.example", { client_ip: "192.0.2.11" }),
|
||||
row(18, "nameless.example", { client_ip: "192.0.2.12" }),
|
||||
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
|
||||
],
|
||||
next_before: null,
|
||||
} satisfies QueriesPage);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with no tooltip standing in.
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
});
|
||||
|
||||
test("load more appends the next page and stops at the end of the log", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
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 refetches and resets the accumulated list", async () => {
|
||||
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(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 = () => {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseLoadMore = () => {
|
||||
resolve(
|
||||
new Response(JSON.stringify(PAGES["/api/queries?before=19"]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined)
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
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: true, block_reason: "blocklist:stevenblack" })],
|
||||
next_before: 7,
|
||||
};
|
||||
const filteredOlderPage: QueriesPage = {
|
||||
queries: [row(3, "ads.older.example")],
|
||||
next_before: null,
|
||||
};
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?domain=ads") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseFiltered = () => {
|
||||
resolve(
|
||||
new Response(JSON.stringify(filteredPage), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
const payload = url === "/api/queries?domain=ads&before=7" ? filteredOlderPage : PAGES[url];
|
||||
if (payload === undefined)
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
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 = screen.getByRole("button", { name: "Load more" });
|
||||
expect(staleButton).toHaveProperty("disabled", true);
|
||||
fireEvent.click(staleButton);
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).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(fetchMock.mock.calls.map((call) => String(call[0]))).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 },
|
||||
"/api/queries?before=19": { queries: [row(18, "n18.example"), row(17, "n17.example")], next_before: null },
|
||||
};
|
||||
const after: Record<string, QueriesPage> = {
|
||||
"/api/queries": { queries: [row(22, "n22.example"), row(21, "n21.example")], next_before: 21 },
|
||||
"/api/queries?before=21": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
},
|
||||
};
|
||||
let live = before;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const payload = live[String(input)];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = 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 client.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: "/queries", search: "", assign });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const payload = PAGES[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" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() => {
|
||||
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/queries")}`);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByText(/Failed to load more/)).toBeNull();
|
||||
});
|
||||
@@ -1,374 +0,0 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import * as api from "@/lib/api";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||
import { ClientName, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { qtypeName } from "./qtype";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
{ value: "allowed", label: "Allowed only" },
|
||||
];
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
/** One column on a phone, two from `sm`, five from `lg`, as before. */
|
||||
filterGrid: {
|
||||
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))",
|
||||
},
|
||||
},
|
||||
filterLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
filterInput: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
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,
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
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",
|
||||
},
|
||||
/** The header tint is a shade off the ground in each scheme, not a token role. */
|
||||
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,
|
||||
},
|
||||
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||
row: {
|
||||
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
nowrap: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
blockedWrap: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
blockedBadge: {
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
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)" },
|
||||
},
|
||||
footer: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
function datetimeLocalToUnix(value: string): number | undefined {
|
||||
if (value === "") return undefined;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||
}
|
||||
|
||||
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
|
||||
if (!row.blocked) return <span {...stylex.props(styles.muted)}>—</span>;
|
||||
return (
|
||||
<span {...stylex.props(styles.blockedWrap)}>
|
||||
<span {...stylex.props(styles.blockedBadge)}>Blocked</span>
|
||||
{row.block_reason !== "" && <span {...stylex.props(styles.small, styles.muted)}>{row.block_reason}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueryCells({ row, clientNames }: { row: Omit<QueryRow, "id">; clientNames: ClientNames }) {
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<BlockedCell row={row} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{row.upstream === "" ? "—" : row.upstream}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueryTableHead() {
|
||||
return (
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
<th {...stylex.props(styles.th)}>Time</th>
|
||||
<th {...stylex.props(styles.th)}>Domain</th>
|
||||
<th {...stylex.props(styles.th)}>Client</th>
|
||||
<th {...stylex.props(styles.th)}>Type</th>
|
||||
<th {...stylex.props(styles.th)}>Status</th>
|
||||
<th {...stylex.props(styles.th)}>Response</th>
|
||||
<th {...stylex.props(styles.th)}>Cache</th>
|
||||
<th {...stylex.props(styles.th)}>Upstream</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueryLogPage() {
|
||||
const [domain, setDomain] = useState("");
|
||||
const [client, setClient] = useState("");
|
||||
const [blocked, setBlocked] = useState("any");
|
||||
const [since, setSince] = useState("");
|
||||
const [until, setUntil] = useState("");
|
||||
|
||||
const [applied, setApplied] = useState<QueriesFilter>({});
|
||||
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
||||
const clientNames = useClientNames();
|
||||
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const filterActive = Object.keys(applied).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 applyFilters(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const filter: QueriesFilter = {};
|
||||
if (domain.trim() !== "") filter.domain = domain.trim();
|
||||
if (client.trim() !== "") filter.client = client.trim();
|
||||
if (blocked === "blocked") filter.blocked = true;
|
||||
if (blocked === "allowed") filter.blocked = false;
|
||||
const sinceTs = datetimeLocalToUnix(since);
|
||||
if (sinceTs !== undefined) filter.since = sinceTs;
|
||||
const untilTs = datetimeLocalToUnix(until);
|
||||
if (untilTs !== undefined) filter.until = untilTs;
|
||||
setApplied(filter);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setDomain("");
|
||||
setClient("");
|
||||
setBlocked("any");
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setApplied({});
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||
void base.fetchNextPage();
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Query Log</h1>
|
||||
|
||||
<form onSubmit={applyFilters} {...stylex.props(styles.filterGrid)}>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Domain contains
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Client (exact)
|
||||
<input
|
||||
type="text"
|
||||
value={client}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
variant="compactField"
|
||||
label="Status"
|
||||
value={blocked}
|
||||
onChange={setBlocked}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Since
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={since}
|
||||
onChange={(event) => setSince(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Until
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
onChange={(event) => setUntil(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button type="submit" {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}>
|
||||
Apply filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{base.isFetching && (
|
||||
<span {...stylex.props(styles.note)} role="status">
|
||||
Loading…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{base.data === undefined ? (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
Loading query log…
|
||||
</p>
|
||||
) : 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)}>
|
||||
<QueryTableHead />
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||
<QueryCells row={row} clientNames={clientNames} />
|
||||
</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.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moreError !== null && (
|
||||
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||
Failed to load more: {moreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
ENUM_VALUES,
|
||||
policyActionLabel,
|
||||
policyReasonLabel,
|
||||
qclassName,
|
||||
rcodeName,
|
||||
routeKindLabel,
|
||||
} from "./provenanceCopy";
|
||||
|
||||
/**
|
||||
* `tsc` proves the maps total over the union; this proves the union is the set
|
||||
* the server actually stores, and that no entry was left as its raw tag name.
|
||||
*/
|
||||
test("every stored enum value has a label of its own", () => {
|
||||
const labels = [
|
||||
...ENUM_VALUES.policyAction.map(policyActionLabel),
|
||||
...ENUM_VALUES.policyReason.map(policyReasonLabel),
|
||||
...ENUM_VALUES.routeKind.map(routeKindLabel),
|
||||
];
|
||||
for (const label of labels) {
|
||||
expect(label).not.toBe("");
|
||||
expect(label).not.toMatch(/_/);
|
||||
}
|
||||
expect(new Set(ENUM_VALUES.policyReason.map(policyReasonLabel)).size).toBe(ENUM_VALUES.policyReason.length);
|
||||
});
|
||||
|
||||
test("response codes read by name where one exists, by number where none does", () => {
|
||||
expect(rcodeName(0)).toBe("NOERROR (0)");
|
||||
expect(rcodeName(3)).toBe("NXDOMAIN (3)");
|
||||
expect(rcodeName(16)).toBe("BADVERS (16)");
|
||||
// The column holds the twelve-bit extended code, most of which is unassigned.
|
||||
expect(rcodeName(3841)).toBe("RCODE 3841");
|
||||
});
|
||||
|
||||
test("query classes read the same way", () => {
|
||||
expect(qclassName(1)).toBe("IN (1)");
|
||||
expect(qclassName(255)).toBe("ANY (255)");
|
||||
expect(qclassName(42)).toBe("CLASS 42");
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { POLICY_ACTIONS, POLICY_REASONS, ROUTE_KINDS } from "@/lib/types";
|
||||
import type { PolicyAction, PolicyReason, RouteKind } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Display names for the three stored enums. `Record` over the union, so a value
|
||||
* added to `src/storage/provenance.zig` and mirrored into `lib/types.ts` fails
|
||||
* `tsc` here instead of reaching a cell as a raw tag name.
|
||||
*/
|
||||
const POLICY_ACTION_LABELS: Record<PolicyAction, string> = {
|
||||
not_evaluated: "Not evaluated",
|
||||
allow: "Allowed",
|
||||
block: "Blocked",
|
||||
};
|
||||
|
||||
const POLICY_REASON_LABELS: Record<PolicyReason, string> = {
|
||||
rule_allow_exact: "Allow rule (exact)",
|
||||
rule_block_exact: "Block rule (exact)",
|
||||
rule_allow_wildcard: "Allow rule (wildcard)",
|
||||
rule_block_wildcard: "Block rule (wildcard)",
|
||||
rule_allow_regex: "Allow rule (regex)",
|
||||
rule_block_regex: "Block rule (regex)",
|
||||
blocklist_exception: "Blocklist exception",
|
||||
blocklist_domain: "Blocklist (domain)",
|
||||
blocklist_wildcard: "Blocklist (wildcard)",
|
||||
local_record: "Local record",
|
||||
forward_zone: "Forward zone",
|
||||
non_in_class: "Not class IN",
|
||||
paused: "Filtering paused",
|
||||
snapshot_unavailable: "No filter snapshot",
|
||||
no_match: "No match",
|
||||
protocol_error: "Protocol refusal",
|
||||
};
|
||||
|
||||
const ROUTE_KIND_LABELS: Record<RouteKind, string> = {
|
||||
blocked: "Blocked locally",
|
||||
local: "Local record",
|
||||
forward_zone: "Forward zone",
|
||||
upstream: "Upstream resolver",
|
||||
cache: "Cache",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
|
||||
export function policyActionLabel(action: PolicyAction): string {
|
||||
return POLICY_ACTION_LABELS[action];
|
||||
}
|
||||
|
||||
export function policyReasonLabel(reason: PolicyReason): string {
|
||||
return POLICY_REASON_LABELS[reason];
|
||||
}
|
||||
|
||||
export function routeKindLabel(kind: RouteKind): string {
|
||||
return ROUTE_KIND_LABELS[kind];
|
||||
}
|
||||
|
||||
/** The enum value sets, for tests that prove the maps exhaustive at runtime too. */
|
||||
export const ENUM_VALUES = {
|
||||
policyAction: POLICY_ACTIONS,
|
||||
policyReason: POLICY_REASONS,
|
||||
routeKind: ROUTE_KINDS,
|
||||
} as const;
|
||||
|
||||
const RCODE_NAMES: Record<number, string> = {
|
||||
0: "NOERROR",
|
||||
1: "FORMERR",
|
||||
2: "SERVFAIL",
|
||||
3: "NXDOMAIN",
|
||||
4: "NOTIMP",
|
||||
5: "REFUSED",
|
||||
6: "YXDOMAIN",
|
||||
7: "YXRRSET",
|
||||
8: "NXRRSET",
|
||||
9: "NOTAUTH",
|
||||
10: "NOTZONE",
|
||||
16: "BADVERS",
|
||||
};
|
||||
|
||||
/**
|
||||
* The bare mnemonic, for a table cell with no room for the number. An
|
||||
* unassigned code has no mnemonic to shorten, so it keeps the same `RCODE <n>`
|
||||
* shape the long form falls back to.
|
||||
*/
|
||||
export function rcodeShortName(rcode: number): string {
|
||||
return RCODE_NAMES[rcode] ?? `RCODE ${rcode}`;
|
||||
}
|
||||
|
||||
/** The twelve-bit extended code as `NXDOMAIN (3)`; an unassigned code keeps its number. */
|
||||
export function rcodeName(rcode: number): string {
|
||||
const name = RCODE_NAMES[rcode];
|
||||
return name === undefined ? `RCODE ${rcode}` : `${name} (${rcode})`;
|
||||
}
|
||||
|
||||
const QCLASS_NAMES: Record<number, string> = {
|
||||
1: "IN",
|
||||
3: "CH",
|
||||
4: "HS",
|
||||
254: "NONE",
|
||||
255: "ANY",
|
||||
};
|
||||
|
||||
export function qclassName(qclass: number): string {
|
||||
const name = QCLASS_NAMES[qclass];
|
||||
return name === undefined ? `CLASS ${qclass}` : `${name} (${qclass})`;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Provenance, QueryRow } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Fixture builders for the provenance shapes, shared by the query-log, detail
|
||||
* and live-stream tests the way `features/activity/fakeEventSource.ts` is shared.
|
||||
*
|
||||
* The defaults describe the dullest possible query — an allowed name nothing
|
||||
* matched, answered upstream — so each test states only the fields it is about.
|
||||
*/
|
||||
type Sections = {
|
||||
[K in keyof Provenance]?: Partial<Provenance[K]>;
|
||||
};
|
||||
|
||||
export function provenance(sections: Sections = {}): Provenance {
|
||||
return {
|
||||
request: {
|
||||
time: 1_700_000_000,
|
||||
domain: "example.com",
|
||||
client: "192.0.2.10",
|
||||
qtype: 1,
|
||||
qclass: 1,
|
||||
...sections.request,
|
||||
},
|
||||
group: { id: 1, name: "default", ...sections.group },
|
||||
policy: {
|
||||
action: "allow",
|
||||
reason: "no_match",
|
||||
matched: "",
|
||||
source_id: null,
|
||||
source_name: "",
|
||||
...sections.policy,
|
||||
},
|
||||
rewrites: { cname_target: "", safe_search_target: "", ...sections.rewrites },
|
||||
route: { kind: "upstream", forward_zone: "", upstream: "https://dns.example/dns-query", ...sections.route },
|
||||
response: { rcode: 0, duration_us: 1234, ...sections.response },
|
||||
};
|
||||
}
|
||||
|
||||
/** The flat stored row of the same dull query. */
|
||||
export function queryRow(id: number, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||
return {
|
||||
id,
|
||||
ts: 1_700_000_000,
|
||||
domain: "example.com",
|
||||
client_ip: "192.0.2.10",
|
||||
qtype: 1,
|
||||
qclass: 1,
|
||||
rcode: 0,
|
||||
blocked: false,
|
||||
response_time_us: 1234,
|
||||
cache_hit: false,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
policy_action: "allow",
|
||||
policy_reason: "no_match",
|
||||
route_kind: "upstream",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { LiveQueryEvent, PolicyReason, QueryRow, RouteKind } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* What the query-log table renders for one row, whichever surface it came from.
|
||||
*
|
||||
* The stored list row and the live stream's provenance event describe the same
|
||||
* query in two different shapes — flat summary against nested full detail — and
|
||||
* both pages share one set of cells, so both project into this.
|
||||
*
|
||||
* `id` is null for a streamed event: the frame precedes its own insert, so no
|
||||
* row exists to link to yet.
|
||||
*/
|
||||
export interface QuerySummary {
|
||||
id: number | null;
|
||||
ts: number;
|
||||
domain: string;
|
||||
client_ip: string;
|
||||
qtype: number | null;
|
||||
blocked: boolean;
|
||||
policy_reason: PolicyReason;
|
||||
/** The twelve-bit extended code the client saw, including a synthesized SERVFAIL. */
|
||||
rcode: number;
|
||||
route_kind: RouteKind;
|
||||
response_time_us: number | null;
|
||||
cache_hit: boolean | null;
|
||||
upstream: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cache answered, or null where it never applied. Mirrors
|
||||
* `Context.cacheHit` in src/server/handler.zig, which derives the stored
|
||||
* `cache_hit` column from the same route: a local record, a blocked answer and
|
||||
* a protocol refusal all bypass the cache, and "miss" would claim a lookup that
|
||||
* never happened.
|
||||
*/
|
||||
export function cacheHitFor(kind: RouteKind): boolean | null {
|
||||
switch (kind) {
|
||||
case "cache":
|
||||
return true;
|
||||
case "upstream":
|
||||
case "forward_zone":
|
||||
return false;
|
||||
case "local":
|
||||
case "blocked":
|
||||
case "rejected":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeRow(row: QueryRow): QuerySummary {
|
||||
return {
|
||||
id: row.id,
|
||||
ts: row.ts,
|
||||
domain: row.domain,
|
||||
client_ip: row.client_ip,
|
||||
qtype: row.qtype,
|
||||
blocked: row.blocked,
|
||||
policy_reason: row.policy_reason,
|
||||
rcode: row.rcode,
|
||||
route_kind: row.route_kind,
|
||||
response_time_us: row.response_time_us,
|
||||
cache_hit: row.cache_hit,
|
||||
upstream: row.upstream,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same summary out of a live frame. `blocked` and `cache_hit` are derived
|
||||
* rather than sent: the server derives the stored columns from exactly these
|
||||
* two fields (handler.zig's `Entry.init` call), so the projection reproduces
|
||||
* them instead of the DTO carrying the same fact twice.
|
||||
*/
|
||||
export function summarizeEvent(event: LiveQueryEvent): QuerySummary {
|
||||
return {
|
||||
id: null,
|
||||
ts: event.request.time,
|
||||
domain: event.request.domain,
|
||||
client_ip: event.request.client,
|
||||
qtype: event.request.qtype,
|
||||
blocked: event.policy.action === "block",
|
||||
policy_reason: event.policy.reason,
|
||||
rcode: event.response.rcode,
|
||||
route_kind: event.route.kind,
|
||||
response_time_us: event.response.duration_us,
|
||||
cache_hit: cacheHitFor(event.route.kind),
|
||||
upstream: event.route.upstream,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ function baseSettings(): Settings {
|
||||
level: "info",
|
||||
retention_days: 30,
|
||||
query_log_buffer_max: 10000,
|
||||
query_log_flush_interval_s: 60,
|
||||
hide_domains: false,
|
||||
hide_client_ips: false,
|
||||
output: "stderr",
|
||||
|
||||
@@ -116,6 +116,7 @@ const SECTIONS: readonly AnySectionDef[] = [
|
||||
{ key: "level", kind: ["error", "warn", "info", "debug"] },
|
||||
{ key: "retention_days", kind: "number" },
|
||||
{ key: "query_log_buffer_max", kind: "number" },
|
||||
{ key: "query_log_flush_interval_s", kind: "number" },
|
||||
{ key: "hide_domains", kind: "boolean" },
|
||||
{ key: "hide_client_ips", kind: "boolean" },
|
||||
{ key: "output", kind: ["stderr", "syslog", "file"] },
|
||||
|
||||
@@ -38,6 +38,7 @@ function baseSettings(): Settings {
|
||||
level: "info",
|
||||
retention_days: 30,
|
||||
query_log_buffer_max: 10000,
|
||||
query_log_flush_interval_s: 60,
|
||||
hide_domains: false,
|
||||
hide_client_ips: false,
|
||||
output: "stderr",
|
||||
|
||||
@@ -99,7 +99,7 @@ test("renders the upstream table and the add form", async () => {
|
||||
expect(disabledToggle.checked).toBe(false);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
|
||||
expect(screen.getByText(/reflects the running pool/)).toBeTruthy();
|
||||
expect(screen.getByText(/counts the running pool/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("adding an upstream posts every field and raises the restart banner", async () => {
|
||||
|
||||
@@ -97,8 +97,8 @@ export default function UpstreamsPage() {
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
|
||||
health table on the Dashboard reflects the running pool, not this list.
|
||||
The pool builds its clients at startup, so an edit here takes effect at the next restart. The Upstreams
|
||||
row on Overview counts the running pool, not this list.
|
||||
</p>
|
||||
|
||||
{upstreams.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import CoverageNotice from "./CoverageNotice";
|
||||
import { formatTime } from "./format";
|
||||
|
||||
const WATERMARK = 1_700_000_000;
|
||||
|
||||
test("a window the log covers in full says nothing", () => {
|
||||
const { container } = render(<CoverageNotice coverage={{ complete: true, available_since: WATERMARK }} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
test("a window reaching past the watermark names the instant history starts", () => {
|
||||
render(<CoverageNotice coverage={{ complete: false, available_since: WATERMARK }} />);
|
||||
const notice = screen.getByRole("status");
|
||||
expect(notice.textContent).toContain("Query history is available from");
|
||||
expect(notice.textContent).toContain(formatTime(WATERMARK));
|
||||
});
|
||||
|
||||
/**
|
||||
* The server judges completeness, not the page: an unbounded request is
|
||||
* incomplete whatever the watermark reads, and the notice must follow that
|
||||
* verdict rather than compare timestamps itself.
|
||||
*/
|
||||
test("the server's verdict decides, not the numbers beside it", () => {
|
||||
const { rerender, container } = render(
|
||||
<CoverageNotice coverage={{ complete: true, available_since: WATERMARK }} />,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
rerender(<CoverageNotice coverage={{ complete: false, available_since: 0 }} />);
|
||||
expect(screen.getByRole("status").textContent).toContain(formatTime(0));
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { Coverage } from "@/lib/types";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
notice: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* How far back the numbers on this page can reach.
|
||||
*
|
||||
* Rendered whenever a response says its window is incomplete, which includes
|
||||
* the common case of a request with no lower bound at all. The line states the
|
||||
* watermark and nothing more: the same incompleteness covers a log retention
|
||||
* has pruned and one that simply has not been running long enough, and the
|
||||
* response does not say which.
|
||||
*/
|
||||
export default function CoverageNotice({ coverage }: { coverage: Coverage }) {
|
||||
if (coverage.complete) return null;
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.notice)}>
|
||||
Query history is available from {formatTime(coverage.available_since)}.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
+33
-3
@@ -6,6 +6,10 @@ import type {
|
||||
ClientEdit,
|
||||
ClientPrefix,
|
||||
ClientPrefixInput,
|
||||
DiagnosticEvent,
|
||||
DiagnosticsFilter,
|
||||
DiagnosticsPage,
|
||||
DiagnosticsPurge,
|
||||
ForwardZone,
|
||||
ForwardZoneInput,
|
||||
Group,
|
||||
@@ -22,17 +26,20 @@ import type {
|
||||
Period,
|
||||
QueriesFilter,
|
||||
QueriesPage,
|
||||
QueryDetail,
|
||||
Rule,
|
||||
RuleEcho,
|
||||
RuleInput,
|
||||
SettingsEnvelope,
|
||||
SettingsPatch,
|
||||
SourceStatus,
|
||||
StatsClients,
|
||||
StatsRoutes,
|
||||
StatsTimeseries,
|
||||
StatsTotals,
|
||||
StatsTypes,
|
||||
Upstream,
|
||||
UpstreamEcho,
|
||||
UpstreamHealth,
|
||||
UpstreamInput,
|
||||
Version,
|
||||
} from "@/lib/types";
|
||||
@@ -108,18 +115,36 @@ export const logout = (): Promise<LogoutResponse> => request("/api/auth/logout",
|
||||
export const getQueries = (filter: QueriesFilter = {}): Promise<QueriesPage> =>
|
||||
request(`/api/queries${qs({ ...filter })}`);
|
||||
|
||||
/** Full provenance of one logged row. 404 when retention has removed it, 503 with no query log. */
|
||||
export const getQueryDetail = (id: number): Promise<QueryDetail> => request(`/api/queries/${id}`);
|
||||
|
||||
/** `EventSource` URL for the live stream; not a fetch route. */
|
||||
export const liveQueriesUrl = "/api/queries/live";
|
||||
|
||||
export const getStats = (period?: Period): Promise<StatsTotals> => request(`/api/stats${qs({ period })}`);
|
||||
export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
|
||||
request(`/api/stats/timeseries${qs({ period })}`);
|
||||
export const getStatsTypes = (period?: Period): Promise<StatsTypes> => request(`/api/stats/types${qs({ period })}`);
|
||||
export const getStatsRoutes = (period?: Period): Promise<StatsRoutes> => request(`/api/stats/routes${qs({ period })}`);
|
||||
export const getStatsClients = (period?: Period): Promise<StatsClients> =>
|
||||
request(`/api/stats/clients${qs({ period })}`);
|
||||
|
||||
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
|
||||
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
|
||||
|
||||
export const getUpstreamHealth = (period?: Period): Promise<UpstreamHealth> =>
|
||||
request(`/api/upstream/health${qs({ period })}`);
|
||||
// Diagnostics
|
||||
|
||||
export const getDiagnostics = (filter: DiagnosticsFilter = {}): Promise<DiagnosticsPage> =>
|
||||
request(`/api/diagnostics${qs({ ...filter })}`);
|
||||
|
||||
export const getDiagnostic = (id: number): Promise<DiagnosticEvent> => request(`/api/diagnostics/${id}`);
|
||||
|
||||
/** Purges one resolved event. An event still active answers 409, an unknown id 404. */
|
||||
export const purgeDiagnostic = (id: number): Promise<void> => request(`/api/diagnostics/${id}`, { method: "DELETE" });
|
||||
|
||||
/** Purges the whole resolved history; active events are never touched. */
|
||||
export const purgeResolvedDiagnostics = (): Promise<DiagnosticsPurge> =>
|
||||
request("/api/diagnostics", { method: "DELETE" });
|
||||
|
||||
// Groups
|
||||
|
||||
@@ -213,6 +238,11 @@ export const deleteUpstream = (id: number): Promise<void> => request(`/api/upstr
|
||||
|
||||
// Pause + settings
|
||||
|
||||
/**
|
||||
* The pause state on its own. Protection is read from `/api/health` everywhere
|
||||
* the interface shows it — one source, one story — so this is left for the live
|
||||
* stream's session probe, which wants the cheapest authenticated GET there is.
|
||||
*/
|
||||
export const getPause = (): Promise<PauseState> => request("/api/pause");
|
||||
export const postPause = (body: PausePost): Promise<PauseState> => request("/api/pause", { method: "POST", body });
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ import type {
|
||||
BlocklistEcho,
|
||||
Client,
|
||||
ClientPrefix,
|
||||
DiagnosticEvent,
|
||||
DiagnosticsPage,
|
||||
DiagnosticsPurge,
|
||||
ErrorEnvelope,
|
||||
ForwardZone,
|
||||
Group,
|
||||
@@ -26,35 +29,46 @@ import type {
|
||||
LookupResult,
|
||||
PauseState,
|
||||
QueriesPage,
|
||||
QueryDetail,
|
||||
Rule,
|
||||
RuleEcho,
|
||||
SettingsEnvelope,
|
||||
SourceStatus,
|
||||
StatsClients,
|
||||
StatsRoutes,
|
||||
StatsTimeseries,
|
||||
StatsTotals,
|
||||
StatsTypes,
|
||||
Upstream,
|
||||
UpstreamEcho,
|
||||
UpstreamHealth,
|
||||
Version,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const sample_get_health: Health = {
|
||||
diagnostics: {
|
||||
active_errors: 0,
|
||||
active_warnings: 0,
|
||||
state: "recording",
|
||||
},
|
||||
disk: {
|
||||
db_bytes: 0,
|
||||
free_bytes: 0,
|
||||
log_bytes: 0,
|
||||
sample_failures: 0,
|
||||
state: "ok",
|
||||
},
|
||||
queries_dropped: 0,
|
||||
refreshes_gated: 0,
|
||||
snapshot_generation: 0,
|
||||
protection: {
|
||||
state: "active",
|
||||
until: null,
|
||||
},
|
||||
query_history: {
|
||||
dropped_total: 0,
|
||||
last_drop_s: null,
|
||||
state: "recording",
|
||||
},
|
||||
status: "ok",
|
||||
upstreams: {
|
||||
available: 0,
|
||||
state: "ok",
|
||||
total: 0,
|
||||
},
|
||||
writer_failed: false,
|
||||
};
|
||||
|
||||
export const sample_get_version: Version = {
|
||||
@@ -73,6 +87,57 @@ export const sample_logout: LogoutResponse = {
|
||||
authenticated: false,
|
||||
};
|
||||
|
||||
export const sample_get_diagnostics: DiagnosticsPage = {
|
||||
active: {
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
code: "upstream_history.write",
|
||||
component: "upstream_history",
|
||||
detail: "Busy",
|
||||
first_seen: 0,
|
||||
id: 0,
|
||||
last_seen: 0,
|
||||
occurrences: 0,
|
||||
resolved_at: 0,
|
||||
severity: "warning",
|
||||
subject: "history",
|
||||
},
|
||||
{
|
||||
code: "blocklist.refresh",
|
||||
component: "blocklist",
|
||||
detail: "download failed: ConnectionTimedOut",
|
||||
first_seen: 0,
|
||||
id: 0,
|
||||
last_seen: 0,
|
||||
occurrences: 0,
|
||||
resolved_at: null,
|
||||
severity: "warning",
|
||||
subject: "StevenBlack",
|
||||
},
|
||||
],
|
||||
next_before: null,
|
||||
};
|
||||
|
||||
export const sample_get_diagnostic: DiagnosticEvent = {
|
||||
code: "blocklist.refresh",
|
||||
component: "blocklist",
|
||||
detail: "download failed: ConnectionTimedOut",
|
||||
first_seen: 0,
|
||||
id: 0,
|
||||
last_seen: 0,
|
||||
occurrences: 0,
|
||||
resolved_at: null,
|
||||
severity: "warning",
|
||||
subject: "StevenBlack",
|
||||
};
|
||||
|
||||
export const sample_purge_diagnostics: DiagnosticsPurge = {
|
||||
purged: 0,
|
||||
};
|
||||
|
||||
export const sample_create_blocklist: BlocklistEcho = {
|
||||
enabled: false,
|
||||
id: 0,
|
||||
@@ -331,101 +396,139 @@ export const sample_update_upstream: UpstreamEcho = {
|
||||
url: "https://dns.example/dns-query",
|
||||
};
|
||||
|
||||
export const sample_get_upstream_health: UpstreamHealth = {
|
||||
available: 0,
|
||||
complete: true,
|
||||
period: "24h",
|
||||
since: 0,
|
||||
total: 0,
|
||||
until: 0,
|
||||
upstreams: [
|
||||
{
|
||||
available: true,
|
||||
enabled: true,
|
||||
period: {
|
||||
attempts: 0,
|
||||
failures: 0,
|
||||
last_failure_at: null,
|
||||
last_failure_error: null,
|
||||
success_rate: null,
|
||||
successes: 0,
|
||||
},
|
||||
url: "https://dns.example/dns-query",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_get_queries: QueriesPage = {
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: false,
|
||||
},
|
||||
next_before: 0,
|
||||
queries: [
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: true,
|
||||
cache_hit: null,
|
||||
client_ip: "192.0.2.11",
|
||||
domain: "shop.example",
|
||||
id: 0,
|
||||
policy_action: "block",
|
||||
policy_reason: "blocklist_wildcard",
|
||||
qclass: 0,
|
||||
qtype: 0,
|
||||
rcode: 0,
|
||||
response_time_us: 0,
|
||||
route_kind: "blocked",
|
||||
ts: 0,
|
||||
upstream: "",
|
||||
},
|
||||
{
|
||||
blocked: false,
|
||||
cache_hit: false,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "news.example",
|
||||
id: 0,
|
||||
policy_action: "allow",
|
||||
policy_reason: "no_match",
|
||||
qclass: 0,
|
||||
qtype: 0,
|
||||
rcode: 0,
|
||||
response_time_us: 0,
|
||||
route_kind: "upstream",
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
blocked: false,
|
||||
cache_hit: true,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d24.example",
|
||||
id: 0,
|
||||
policy_action: "allow",
|
||||
policy_reason: "no_match",
|
||||
qclass: 0,
|
||||
qtype: 0,
|
||||
rcode: 0,
|
||||
response_time_us: 0,
|
||||
route_kind: "cache",
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
upstream: "",
|
||||
},
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: false,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d23.example",
|
||||
id: 0,
|
||||
policy_action: "allow",
|
||||
policy_reason: "no_match",
|
||||
qclass: 0,
|
||||
qtype: 0,
|
||||
rcode: 0,
|
||||
response_time_us: 0,
|
||||
route_kind: "upstream",
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: true,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d22.example",
|
||||
id: 0,
|
||||
policy_action: "allow",
|
||||
policy_reason: "no_match",
|
||||
qclass: 0,
|
||||
qtype: 0,
|
||||
rcode: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: false,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d21.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "blocklist_domain",
|
||||
blocked: true,
|
||||
cache_hit: null,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d20.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
route_kind: "cache",
|
||||
ts: 0,
|
||||
upstream: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_get_query_detail: QueryDetail = {
|
||||
group: {
|
||||
id: 0,
|
||||
name: "kids",
|
||||
},
|
||||
id: 0,
|
||||
policy: {
|
||||
action: "block",
|
||||
matched: "||tracker.example^",
|
||||
reason: "blocklist_wildcard",
|
||||
source_id: 0,
|
||||
source_name: "StevenBlack",
|
||||
},
|
||||
request: {
|
||||
client: "192.0.2.11",
|
||||
domain: "shop.example",
|
||||
qclass: 0,
|
||||
qtype: 0,
|
||||
time: 0,
|
||||
},
|
||||
response: {
|
||||
duration_us: 0,
|
||||
rcode: 0,
|
||||
},
|
||||
rewrites: {
|
||||
cname_target: "cdn.tracker.example",
|
||||
safe_search_target: "",
|
||||
},
|
||||
route: {
|
||||
forward_zone: "",
|
||||
kind: "blocked",
|
||||
upstream: "",
|
||||
},
|
||||
};
|
||||
|
||||
export const sample_get_stats: StatsTotals = {
|
||||
avg_response_time_us: null,
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
clients: 0,
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: true,
|
||||
},
|
||||
period: "1h",
|
||||
queries: 0,
|
||||
since: 0,
|
||||
@@ -442,6 +545,10 @@ export const sample_get_stats_timeseries: StatsTimeseries = {
|
||||
ts: 0,
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: true,
|
||||
},
|
||||
period: "1h",
|
||||
since: 0,
|
||||
until: 0,
|
||||
@@ -498,6 +605,7 @@ export const sample_get_settings: SettingsEnvelope = {
|
||||
"logging.level",
|
||||
"logging.retention_days",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.query_log_flush_interval_s",
|
||||
"logging.hide_domains",
|
||||
"logging.hide_client_ips",
|
||||
"logging.output",
|
||||
@@ -559,6 +667,7 @@ export const sample_get_settings: SettingsEnvelope = {
|
||||
max_size_mb: 0,
|
||||
output: "stderr",
|
||||
query_log_buffer_max: 0,
|
||||
query_log_flush_interval_s: 0,
|
||||
retention_days: 0,
|
||||
},
|
||||
upstream: {
|
||||
@@ -621,6 +730,7 @@ export const sample_put_settings: SettingsEnvelope = {
|
||||
"logging.level",
|
||||
"logging.retention_days",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.query_log_flush_interval_s",
|
||||
"logging.hide_domains",
|
||||
"logging.hide_client_ips",
|
||||
"logging.output",
|
||||
@@ -682,6 +792,7 @@ export const sample_put_settings: SettingsEnvelope = {
|
||||
max_size_mb: 0,
|
||||
output: "stderr",
|
||||
query_log_buffer_max: 0,
|
||||
query_log_flush_interval_s: 0,
|
||||
retention_days: 0,
|
||||
},
|
||||
upstream: {
|
||||
@@ -715,6 +826,104 @@ export const sample_error_not_found: ErrorEnvelope = {
|
||||
error: "not found",
|
||||
};
|
||||
|
||||
export const sample_get_stats_types: StatsTypes = {
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: true,
|
||||
},
|
||||
period: "1h",
|
||||
since: 0,
|
||||
types: [
|
||||
{
|
||||
count: 0,
|
||||
qtype: 0,
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
qtype: null,
|
||||
},
|
||||
],
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_stats_routes: StatsRoutes = {
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: true,
|
||||
},
|
||||
period: "1h",
|
||||
routes: [
|
||||
{
|
||||
count: 0,
|
||||
route: "upstream",
|
||||
source: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
route: "blocked",
|
||||
source: null,
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
route: "cache",
|
||||
source: null,
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
route: "forward_zone",
|
||||
source: "lan",
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
route: "local",
|
||||
source: null,
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
route: "rejected",
|
||||
source: null,
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
route: "upstream",
|
||||
source: "https://dns2.example/dns-query",
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
route: "upstream",
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_stats_clients: StatsClients = {
|
||||
bucket_seconds: 0,
|
||||
clients: [
|
||||
{
|
||||
buckets: [0],
|
||||
client: "192.0.2.30",
|
||||
},
|
||||
{
|
||||
buckets: [0],
|
||||
client: "192.0.2.31",
|
||||
},
|
||||
{
|
||||
buckets: [0],
|
||||
client: "192.0.2.32",
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: true,
|
||||
},
|
||||
other: [0],
|
||||
period: "1h",
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_error_unauthorized: ErrorEnvelope = {
|
||||
error: "authentication required",
|
||||
};
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { formatAge, formatBytes, formatMicros, formatTime } from "@/lib/format";
|
||||
import { formatBytes, formatClock, formatDuration, formatMicros, formatTime } from "@/lib/format";
|
||||
|
||||
test("formatTime renders unix seconds in the given locale and zone", () => {
|
||||
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
|
||||
expect(formatTime(1704067200, "en-US", "UTC").replace(/ /g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
|
||||
});
|
||||
|
||||
test("formatClock states the time of day alone, for a stamp read against now", () => {
|
||||
expect(formatClock(Date.UTC(2026, 0, 1, 14, 5) / 1000, "en-GB", "UTC")).toBe("14:05");
|
||||
expect(formatClock(Date.UTC(2026, 0, 1, 9, 30) / 1000, "en-GB", "UTC")).toBe("09:30");
|
||||
// No date: the caller places it against now, and a date would be noise.
|
||||
expect(formatClock(Date.UTC(2026, 0, 1, 14, 5) / 1000, "en-GB", "UTC")).not.toMatch(/2026/);
|
||||
});
|
||||
|
||||
test("formatBytes humanizes with binary units", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(1023)).toBe("1023 B");
|
||||
@@ -15,16 +22,21 @@ test("formatBytes humanizes with binary units", () => {
|
||||
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
|
||||
});
|
||||
|
||||
test("formatAge steps up a unit at each boundary and truncates", () => {
|
||||
expect(formatAge(0)).toBe("0s ago");
|
||||
expect(formatAge(59)).toBe("59s ago");
|
||||
expect(formatAge(60)).toBe("1m ago");
|
||||
expect(formatAge(3599)).toBe("59m ago");
|
||||
expect(formatAge(3600)).toBe("1h ago");
|
||||
expect(formatAge(10800)).toBe("3h ago");
|
||||
expect(formatAge(86399)).toBe("23h ago");
|
||||
expect(formatAge(86400)).toBe("1d ago");
|
||||
expect(formatAge(400000)).toBe("4d ago");
|
||||
test("formatDuration steps up a unit at each boundary and truncates", () => {
|
||||
expect(formatDuration(0)).toBe("0s");
|
||||
expect(formatDuration(59)).toBe("59s");
|
||||
expect(formatDuration(60)).toBe("1m");
|
||||
expect(formatDuration(3599)).toBe("59m");
|
||||
expect(formatDuration(3600)).toBe("1h");
|
||||
expect(formatDuration(10800)).toBe("3h");
|
||||
expect(formatDuration(86399)).toBe("23h");
|
||||
expect(formatDuration(86400)).toBe("1d");
|
||||
expect(formatDuration(400000)).toBe("4d");
|
||||
});
|
||||
|
||||
test("formatDuration is never negative", () => {
|
||||
// Clock skew between the server's timestamps and the browser's clock.
|
||||
expect(formatDuration(-5)).toBe("0s");
|
||||
});
|
||||
|
||||
test("formatMicros renders milliseconds with one decimal", () => {
|
||||
|
||||
+17
-6
@@ -7,6 +7,17 @@ export function formatTime(unixSeconds: number, locale?: string, timeZone?: stri
|
||||
}).format(new Date(unixSeconds * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix seconds → the time of day alone, "14:05". For a stamp the reader places
|
||||
* against now — a pause that ends shortly, the last row that was dropped —
|
||||
* where the date would be noise on every reading but one.
|
||||
*/
|
||||
export function formatClock(unixSeconds: number, locale?: string, timeZone?: string): string {
|
||||
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone }).format(
|
||||
new Date(unixSeconds * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
@@ -28,15 +39,15 @@ const AGE_UNITS = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Seconds of elapsed time → a coarse "3h ago". Truncating and single-unit on
|
||||
* purpose: this labels a snapshot the caller renders once, so a reader must not
|
||||
* take it for a live count. Nothing re-renders it as it ages.
|
||||
* Seconds of elapsed time → a coarse "3h". Truncating and single-unit on
|
||||
* purpose, for a span the caller labels itself, as in "active for 3h". A
|
||||
* negative span reads "0s": clock skew is not a duration.
|
||||
*/
|
||||
export function formatAge(seconds: number): string {
|
||||
export function formatDuration(seconds: number): string {
|
||||
for (const unit of AGE_UNITS) {
|
||||
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix} ago`;
|
||||
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix}`;
|
||||
}
|
||||
return `${Math.floor(seconds)}s ago`;
|
||||
return `${Math.max(0, Math.floor(seconds))}s`;
|
||||
}
|
||||
|
||||
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* A healthy `GET /api/health` body, for tests that need protection to be a
|
||||
* settled fact rather than the subject under test. Overrides are per condition,
|
||||
* so a test names only the one it is about.
|
||||
*/
|
||||
|
||||
import type { Health } from "./types";
|
||||
|
||||
export function health(overrides: Partial<Health> = {}): Health {
|
||||
return {
|
||||
status: "ok",
|
||||
protection: { state: "active", until: null },
|
||||
upstreams: { state: "ok", available: 2, total: 2 },
|
||||
query_history: { state: "recording", dropped_total: 0, last_drop_s: null },
|
||||
diagnostics: { state: "recording", active_warnings: 0, active_errors: 0 },
|
||||
disk: { state: "ok", free_bytes: 40 * 1024 * 1024 * 1024 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
+72
-15
@@ -5,6 +5,8 @@ import type {
|
||||
BlocklistInput,
|
||||
ClientEdit,
|
||||
ClientPrefixInput,
|
||||
DiagnosticsFilter,
|
||||
DiagnosticsPage,
|
||||
ForwardZoneInput,
|
||||
GroupInput,
|
||||
LocalRecordInput,
|
||||
@@ -22,8 +24,15 @@ export const queryKeys = {
|
||||
version: ["version"] as const,
|
||||
stats: (period: Period) => ["stats", period] as const,
|
||||
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
|
||||
statsTypes: (period: Period) => ["stats", "types", period] as const,
|
||||
statsRoutes: (period: Period) => ["stats", "routes", period] as const,
|
||||
statsClients: (period: Period) => ["stats", "clients", period] as const,
|
||||
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
|
||||
upstreamHealth: (period: Period) => ["upstream-health", period] as const,
|
||||
queryDetail: (id: number) => ["queries", "detail", id] as const,
|
||||
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
|
||||
diagnostic: (id: number) => ["diagnostics", "event", id] as const,
|
||||
/** Prefix of every diagnostics entry, page and detail alike; the purge target. */
|
||||
diagnosticsAll: ["diagnostics"] as const,
|
||||
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
|
||||
/** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */
|
||||
lookupAll: ["lookup"] as const,
|
||||
@@ -36,7 +45,6 @@ export const queryKeys = {
|
||||
clients: ["clients"] as const,
|
||||
clientPrefixes: ["client-prefixes"] as const,
|
||||
upstreams: ["upstreams"] as const,
|
||||
pause: ["pause"] as const,
|
||||
settings: ["settings"] as const,
|
||||
};
|
||||
|
||||
@@ -56,6 +64,27 @@ export const timeseriesQuery = (period: Period = "24h") =>
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const statsTypesQuery = (period: Period = "24h") =>
|
||||
queryOptions({
|
||||
queryKey: queryKeys.statsTypes(period),
|
||||
queryFn: () => api.getStatsTypes(period),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const statsRoutesQuery = (period: Period = "24h") =>
|
||||
queryOptions({
|
||||
queryKey: queryKeys.statsRoutes(period),
|
||||
queryFn: () => api.getStatsRoutes(period),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const statsClientsQuery = (period: Period = "24h") =>
|
||||
queryOptions({
|
||||
queryKey: queryKeys.statsClients(period),
|
||||
queryFn: () => api.getStatsClients(period),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Keyset pagination on `next_before` (handlers/queries.zig). A background
|
||||
// refetch replays every page in cursor order, so newly logged rows shift the
|
||||
// whole window instead of opening a gap between page 1 and page 2.
|
||||
@@ -69,16 +98,31 @@ export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// The period is part of the key: the upstream aggregates are ranged like the
|
||||
// stats ones, so the picker has to refetch them rather than reuse a cached
|
||||
// window under a new label.
|
||||
export const upstreamHealthQuery = (period: Period = "24h") =>
|
||||
queryOptions({
|
||||
queryKey: queryKeys.upstreamHealth(period),
|
||||
queryFn: () => api.getUpstreamHealth(period),
|
||||
refetchInterval: 30_000,
|
||||
export const queryDetailQuery = (id: number) =>
|
||||
queryOptions({ queryKey: queryKeys.queryDetail(id), queryFn: () => api.getQueryDetail(id) });
|
||||
|
||||
// Keyset pagination on `next_before`, exactly as the query log pages
|
||||
// (handlers/diagnostics.zig copies the /api/queries contract). The active view
|
||||
// polls on healthQuery's cadence because an episode opening is the same news a
|
||||
// health banner carries; a resolved-history page is settled and does not poll.
|
||||
// `enabled` belongs to the factory rather than to a spread at the call site:
|
||||
// spreading the options object loses the page-param type, and the Diagnostics
|
||||
// page turns one of its two sections off whenever a filter excludes it.
|
||||
export const diagnosticsInfiniteQuery = (filter: DiagnosticsFilter = {}, enabled = true) =>
|
||||
infiniteQueryOptions({
|
||||
enabled,
|
||||
queryKey: queryKeys.diagnosticsInfinite(filter),
|
||||
queryFn: ({ pageParam }: { pageParam: number | undefined }) =>
|
||||
api.getDiagnostics(pageParam === undefined ? filter : { ...filter, before: pageParam }),
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (last: DiagnosticsPage) => last.next_before ?? undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: filter.state === "active" ? 10_000 : undefined,
|
||||
});
|
||||
|
||||
export const diagnosticQuery = (id: number) =>
|
||||
queryOptions({ queryKey: queryKeys.diagnostic(id), queryFn: () => api.getDiagnostic(id) });
|
||||
|
||||
export const lookupQuery = (domain: string, groupId?: number) =>
|
||||
queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) });
|
||||
|
||||
@@ -104,14 +148,25 @@ export const clientPrefixesQuery = () =>
|
||||
|
||||
export const upstreamsQuery = () => queryOptions({ queryKey: queryKeys.upstreams, queryFn: api.listUpstreams });
|
||||
|
||||
export const pauseQuery = () => queryOptions({ queryKey: queryKeys.pause, queryFn: api.getPause });
|
||||
|
||||
export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings, queryFn: api.getSettings });
|
||||
|
||||
// Mutation option factories. Usage: useMutation(groupCreateMutation(useQueryClient())).
|
||||
// Group membership and names feed lookup verdicts and the group columns on
|
||||
// clients, prefixes and rules, hence the wide invalidation on group mutations.
|
||||
|
||||
// Both purges invalidate the whole `diagnostics` prefix rather than one page
|
||||
// key: the resolved list, the active list (whose `active` counts ride along) and
|
||||
// the detail query of the row just deleted all describe the table that changed.
|
||||
export const diagnosticPurgeMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (id: number) => api.purgeDiagnostic(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.diagnosticsAll }),
|
||||
});
|
||||
|
||||
export const diagnosticsPurgeResolvedMutation = (qc: QueryClient) => ({
|
||||
mutationFn: () => api.purgeResolvedDiagnostics(),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.diagnosticsAll }),
|
||||
});
|
||||
|
||||
function invalidateGroupWorld(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.groups }),
|
||||
@@ -277,11 +332,13 @@ export const upstreamDeleteMutation = (qc: QueryClient) => ({
|
||||
onSuccess: () => invalidateUpstreams(qc),
|
||||
});
|
||||
|
||||
// Protection is a health condition, and health is the only thing that reads it:
|
||||
// the sidebar control, the Diagnostics health strip and the related action on a
|
||||
// blocked query all render `Health.protection`. Without this invalidation they
|
||||
// would contradict a successful mutation until the next ten-second poll.
|
||||
export const pauseMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (body: PausePost) => api.postPause(body),
|
||||
onSuccess: (state: Awaited<ReturnType<typeof api.postPause>>) => {
|
||||
qc.setQueryData(queryKeys.pause, state);
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.health }),
|
||||
});
|
||||
|
||||
export const settingsPutMutation = (qc: QueryClient) => ({
|
||||
|
||||
@@ -25,6 +25,7 @@ function baseSettings(): Settings {
|
||||
level: "info",
|
||||
retention_days: 30,
|
||||
query_log_buffer_max: 10000,
|
||||
query_log_flush_interval_s: 60,
|
||||
hide_domains: false,
|
||||
hide_client_ips: false,
|
||||
output: "stderr",
|
||||
|
||||
+281
-50
@@ -11,23 +11,54 @@ export interface ErrorEnvelope {
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five conditions `GET /api/health` reports, and a `status` computed from
|
||||
* exactly their states. Nothing degrades the rollup without appearing here, so
|
||||
* a reader of this object can always name what degraded the box.
|
||||
*/
|
||||
export interface Health {
|
||||
status: "ok" | "degraded";
|
||||
disk: {
|
||||
state: "ok" | "warn" | "critical";
|
||||
free_bytes: number;
|
||||
db_bytes: number;
|
||||
log_bytes: number;
|
||||
sample_failures: number;
|
||||
/**
|
||||
* Is filtering in force. `unavailable` is not an operator's doing: it is the
|
||||
* state in which the query path has no filter snapshot to evaluate against.
|
||||
* It outranks a pause, which is why `until` is null under it.
|
||||
*/
|
||||
protection: {
|
||||
state: "active" | "paused" | "unavailable";
|
||||
/** The second filtering resumes at; null for an indefinite pause and for every other state. */
|
||||
until: number | null;
|
||||
};
|
||||
upstreams: {
|
||||
state: "ok" | "unavailable";
|
||||
available: number;
|
||||
/** Enabled upstreams: the pool is built from those alone. */
|
||||
total: number;
|
||||
};
|
||||
queries_dropped: number;
|
||||
writer_failed: boolean;
|
||||
refreshes_gated: number;
|
||||
snapshot_generation: number | null;
|
||||
/**
|
||||
* Whether Activity can be trusted. `dropped_total` is cumulative and does not
|
||||
* decide the state — a drop an hour ago is not a fault now.
|
||||
*/
|
||||
query_history: {
|
||||
state: "recording" | "losing" | "failed";
|
||||
dropped_total: number;
|
||||
/** The newest drop; stamped by a separate atomic, so it can lag a non-zero count. */
|
||||
last_drop_s: number | null;
|
||||
};
|
||||
/**
|
||||
* The diagnostics store's own state, not a summary of what it holds:
|
||||
* `unavailable` means the store is missing or its last write failed, so the
|
||||
* counts below are the last ones it managed to observe.
|
||||
*/
|
||||
diagnostics: {
|
||||
state: "recording" | "unavailable";
|
||||
active_warnings: number;
|
||||
active_errors: number;
|
||||
};
|
||||
/** `low` is the monitor's `warn` renamed at the wire: warn reads as a log level. */
|
||||
disk: {
|
||||
state: "ok" | "low" | "critical";
|
||||
free_bytes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
@@ -50,25 +81,133 @@ export interface LogoutResponse {
|
||||
authenticated: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three closed enums `src/storage/provenance.zig` stores, as values rather
|
||||
* than bare types: the copy maps in `features/queries/provenanceCopy.ts` have to
|
||||
* be proven exhaustive at runtime as well as by `tsc`, exactly as
|
||||
* `DIAGNOSTIC_CODES` below.
|
||||
*/
|
||||
export const POLICY_ACTIONS = ["not_evaluated", "allow", "block"] as const;
|
||||
export type PolicyAction = (typeof POLICY_ACTIONS)[number];
|
||||
|
||||
export const POLICY_REASONS = [
|
||||
"rule_allow_exact",
|
||||
"rule_block_exact",
|
||||
"rule_allow_wildcard",
|
||||
"rule_block_wildcard",
|
||||
"rule_allow_regex",
|
||||
"rule_block_regex",
|
||||
"blocklist_exception",
|
||||
"blocklist_domain",
|
||||
"blocklist_wildcard",
|
||||
"local_record",
|
||||
"forward_zone",
|
||||
"non_in_class",
|
||||
"paused",
|
||||
"snapshot_unavailable",
|
||||
"no_match",
|
||||
"protocol_error",
|
||||
] as const;
|
||||
export type PolicyReason = (typeof POLICY_REASONS)[number];
|
||||
|
||||
export const ROUTE_KINDS = ["blocked", "local", "forward_zone", "upstream", "cache", "rejected"] as const;
|
||||
export type RouteKind = (typeof ROUTE_KINDS)[number];
|
||||
|
||||
/** The summary projection the query-log table scans; full provenance is at `/api/queries/{id}`. */
|
||||
export interface QueryRow {
|
||||
id: number;
|
||||
ts: number;
|
||||
domain: string;
|
||||
client_ip: string;
|
||||
qtype: number | null;
|
||||
qclass: number;
|
||||
rcode: number;
|
||||
blocked: boolean;
|
||||
block_reason: string;
|
||||
response_time_us: number | null;
|
||||
cache_hit: boolean | null;
|
||||
upstream: string;
|
||||
policy_action: PolicyAction;
|
||||
policy_reason: PolicyReason;
|
||||
route_kind: RouteKind;
|
||||
}
|
||||
|
||||
/** SSE `event: query` payload: a QueryRow minus `id` (precedes persistence). */
|
||||
export type LiveQueryEvent = Omit<QueryRow, "id">;
|
||||
export interface ProvenanceRequest {
|
||||
time: number;
|
||||
domain: string;
|
||||
client: string;
|
||||
qtype: number | null;
|
||||
qclass: number;
|
||||
}
|
||||
|
||||
/** The group as a historical fact: the id may name a group since renamed or deleted. */
|
||||
export interface ProvenanceGroup {
|
||||
id: number | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProvenancePolicy {
|
||||
action: PolicyAction;
|
||||
reason: PolicyReason;
|
||||
matched: string;
|
||||
source_id: number | null;
|
||||
source_name: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceRewrites {
|
||||
cname_target: string;
|
||||
safe_search_target: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceRoute {
|
||||
kind: RouteKind;
|
||||
forward_zone: string;
|
||||
/** Non-empty only for an attempted upstream or forward-zone exchange; already redacted. */
|
||||
upstream: string;
|
||||
}
|
||||
|
||||
export interface ProvenanceResponse {
|
||||
/** The twelve-bit EDNS extended code, not the four header bits alone. */
|
||||
rcode: number;
|
||||
duration_us: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One query, fully explained, in the order a query meets the pipeline
|
||||
* (`src/web/provenance_view.zig`). Every text field follows the repository
|
||||
* convention: `""` means absent.
|
||||
*/
|
||||
export interface Provenance {
|
||||
request: ProvenanceRequest;
|
||||
group: ProvenanceGroup;
|
||||
policy: ProvenancePolicy;
|
||||
rewrites: ProvenanceRewrites;
|
||||
route: ProvenanceRoute;
|
||||
response: ProvenanceResponse;
|
||||
}
|
||||
|
||||
/** `GET /api/queries/{id}`: the same six groups plus the row id. */
|
||||
export interface QueryDetail extends Provenance {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/** SSE `event: query` payload: the full provenance, minus an id it cannot have yet. */
|
||||
export type LiveQueryEvent = Provenance;
|
||||
|
||||
/**
|
||||
* How much of the requested window the query log can answer for. Retention
|
||||
* deletes rows and advances the watermark in one transaction, so an empty
|
||||
* window is distinguishable from a pruned one.
|
||||
*/
|
||||
export interface Coverage {
|
||||
complete: boolean;
|
||||
/** The oldest instant the log is complete for, unix seconds. */
|
||||
available_since: number;
|
||||
}
|
||||
|
||||
export interface QueriesPage {
|
||||
queries: QueryRow[];
|
||||
next_before: number | null;
|
||||
coverage: Coverage;
|
||||
}
|
||||
|
||||
export interface QueriesFilter {
|
||||
@@ -81,15 +220,86 @@ export interface QueriesFilter {
|
||||
until?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fifteen operational event codes, in the order `src/storage/events.zig`
|
||||
* declares them. A value, not only a type, because the copy map has to be
|
||||
* proven exhaustive at runtime as well as by `tsc`.
|
||||
*/
|
||||
export const DIAGNOSTIC_CODES = [
|
||||
"disk.space",
|
||||
"disk.probe",
|
||||
"blocklist.refresh",
|
||||
"blocklist.snapshot",
|
||||
"blocklist.storage",
|
||||
"certificate.reload",
|
||||
"query_log.write",
|
||||
"query_log.maintenance",
|
||||
"query_log.recreated",
|
||||
"upstream_history.write",
|
||||
"upstream.exchange",
|
||||
"client_names.storage",
|
||||
"clients.storage",
|
||||
"listener.start",
|
||||
"configuration.load",
|
||||
] as const;
|
||||
|
||||
export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[number];
|
||||
|
||||
export type DiagnosticSeverity = "warning" | "error";
|
||||
|
||||
/** Which episodes a query selects. `all` is the server's default. */
|
||||
export type DiagnosticState = "active" | "resolved" | "all";
|
||||
|
||||
export interface DiagnosticEvent {
|
||||
id: number;
|
||||
code: DiagnosticCode;
|
||||
/** The part of `code` before the dot, repeated by the server for filtering. */
|
||||
component: string;
|
||||
/** The display identity of what failed; redacted where it derives from a url. */
|
||||
subject: string;
|
||||
severity: DiagnosticSeverity;
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
occurrences: number;
|
||||
/** Null while the episode is still open. */
|
||||
resolved_at: number | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticsPage {
|
||||
events: DiagnosticEvent[];
|
||||
next_before: number | null;
|
||||
/** Episodes open right now, whatever this page filtered to. */
|
||||
active: {
|
||||
warnings: number;
|
||||
errors: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** `DELETE /api/diagnostics` — how many resolved events the purge removed. */
|
||||
export interface DiagnosticsPurge {
|
||||
purged: number;
|
||||
}
|
||||
|
||||
export interface DiagnosticsFilter {
|
||||
state?: DiagnosticState;
|
||||
severity?: DiagnosticSeverity;
|
||||
component?: string;
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
before?: number;
|
||||
}
|
||||
|
||||
export interface StatsTotals {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
queries: number;
|
||||
blocked: number;
|
||||
cached: number;
|
||||
clients: number;
|
||||
avg_response_time_us: number | null;
|
||||
coverage: Coverage;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
@@ -105,6 +315,62 @@ export interface StatsTimeseries {
|
||||
until: number;
|
||||
bucket_seconds: number;
|
||||
buckets: Bucket[];
|
||||
coverage: Coverage;
|
||||
}
|
||||
|
||||
/**
|
||||
* One DNS type's share of the window. `qtype` is the numeric code as logged:
|
||||
* naming it is the admin's job (`features/queries/qtype.ts`), and a row whose
|
||||
* type was never recorded keeps its own `null` group rather than disappearing.
|
||||
*/
|
||||
export interface StatsTypeRow {
|
||||
qtype: number | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface StatsTypes {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
types: StatsTypeRow[];
|
||||
coverage: Coverage;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the window's queries were answered. `source` names the answering upstream
|
||||
* on `upstream` rows and the zone on `forward_zone` rows; every other route kind
|
||||
* carries null, as does a row whose identity was not recorded.
|
||||
*/
|
||||
export interface StatsRouteRow {
|
||||
route: RouteKind;
|
||||
source: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface StatsRoutes {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
routes: StatsRouteRow[];
|
||||
coverage: Coverage;
|
||||
}
|
||||
|
||||
/** One client's per-bucket counts, aligned to `StatsTimeseries`'s buckets. */
|
||||
export interface StatsClientSeries {
|
||||
client: string;
|
||||
buckets: number[];
|
||||
}
|
||||
|
||||
export interface StatsClients {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
bucket_seconds: number;
|
||||
/** The eight busiest clients in the window, ranked by total count. */
|
||||
clients: StatsClientSeries[];
|
||||
/** Everything outside the top eight. Always present and always bucket-count-sized. */
|
||||
other: number[];
|
||||
coverage: Coverage;
|
||||
}
|
||||
|
||||
export interface LookupResult {
|
||||
@@ -119,42 +385,6 @@ export interface LookupResult {
|
||||
safe_search_rewrite: string | null;
|
||||
}
|
||||
|
||||
export interface UpstreamPeriodStats {
|
||||
attempts: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
/** successes/attempts, 0 to 1; null when attempts is 0 — no observations is not perfect reliability. */
|
||||
success_rate: number | null;
|
||||
/** The newest failure inside the window, unix seconds; null when the window holds none. */
|
||||
last_failure_at: number | null;
|
||||
/** The error name belonging to last_failure_at; null exactly when it is. */
|
||||
last_failure_error: string | null;
|
||||
}
|
||||
|
||||
export interface UpstreamHealthEntry {
|
||||
url: string;
|
||||
/** Live configuration, not history. */
|
||||
enabled: boolean;
|
||||
/** Live state; false while the upstream is backing off. */
|
||||
available: boolean;
|
||||
period: UpstreamPeriodStats;
|
||||
}
|
||||
|
||||
export interface UpstreamHealth {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
available: number;
|
||||
total: number;
|
||||
/**
|
||||
* No capacity drops known in this process within the selected window; up to about a minute of
|
||||
* the newest outcomes may not have flushed yet, and outcomes lost in an unclean shutdown are
|
||||
* not detectable.
|
||||
*/
|
||||
complete: boolean;
|
||||
upstreams: UpstreamHealthEntry[];
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -388,6 +618,7 @@ export interface Settings {
|
||||
level: "error" | "warn" | "info" | "debug";
|
||||
retention_days: number;
|
||||
query_log_buffer_max: number;
|
||||
query_log_flush_interval_s: number;
|
||||
hide_domains: boolean;
|
||||
hide_client_ips: boolean;
|
||||
output: "stderr" | "syslog" | "file";
|
||||
|
||||
+183
-28
@@ -5,6 +5,7 @@ import {
|
||||
createRoute,
|
||||
createRouter,
|
||||
lazyRouteComponent,
|
||||
redirect,
|
||||
useRouter,
|
||||
type ErrorComponentProps,
|
||||
type RouterHistory,
|
||||
@@ -12,22 +13,37 @@ import {
|
||||
import AppShell from "@/shell/AppShell";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { diagnosticsFilterOf, type DiagnosticsSearch } from "@/features/diagnostics/filter";
|
||||
import {
|
||||
queriesFilterOf,
|
||||
validateActivitySearch,
|
||||
validateText,
|
||||
validateTimestamp,
|
||||
type ActivitySearch,
|
||||
} from "@/features/activity/search";
|
||||
import {
|
||||
blocklistsQuery,
|
||||
clientPrefixesQuery,
|
||||
clientsQuery,
|
||||
diagnosticQuery,
|
||||
diagnosticsInfiniteQuery,
|
||||
forwardZonesQuery,
|
||||
groupsQuery,
|
||||
healthQuery,
|
||||
localRecordsQuery,
|
||||
queriesInfiniteQuery,
|
||||
queryDetailQuery,
|
||||
rulesQuery,
|
||||
settingsQuery,
|
||||
statsClientsQuery,
|
||||
statsQuery,
|
||||
statsRoutesQuery,
|
||||
statsTypesQuery,
|
||||
timeseriesQuery,
|
||||
upstreamHealthQuery,
|
||||
upstreamsQuery,
|
||||
} from "@/lib/queries";
|
||||
import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period";
|
||||
import type { Period } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
@@ -119,33 +135,119 @@ const shellRoute = createRoute({
|
||||
component: AppShell,
|
||||
});
|
||||
|
||||
const dashboardRoute = createRoute({
|
||||
/**
|
||||
* The landing default, not a compatibility alias: Overview is where the app
|
||||
* opens, and `/` is spelled out rather than left as a second name for it.
|
||||
*/
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/",
|
||||
// allSettled, not all: DashboardPage reads these with useQuery so each widget
|
||||
// can render its own error. A rejecting loader would replace the whole page
|
||||
// with RouteError and take the three healthy widgets down with the failed one.
|
||||
loader: ({ context }) =>
|
||||
Promise.allSettled([
|
||||
context.queryClient.ensureQueryData(statsQuery("24h")),
|
||||
context.queryClient.ensureQueryData(timeseriesQuery("24h")),
|
||||
context.queryClient.ensureQueryData(healthQuery()),
|
||||
context.queryClient.ensureQueryData(upstreamHealthQuery()),
|
||||
]),
|
||||
component: lazyRouteComponent(() => import("@/features/dashboard/DashboardPage")),
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/overview" });
|
||||
},
|
||||
});
|
||||
|
||||
const queriesRoute = createRoute({
|
||||
/**
|
||||
* Overview. The period is the whole of its applied state, so a view of the page
|
||||
* is a link: a hand-typed or stale value falls back to the default rather than
|
||||
* reaching the API as a parameter it answers 400 to.
|
||||
*/
|
||||
const overviewRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/queries",
|
||||
loader: ({ context }) => context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery({})),
|
||||
component: lazyRouteComponent(() => import("@/features/queries/QueryLogPage")),
|
||||
path: "/overview",
|
||||
validateSearch: (search: Record<string, unknown>): { period?: Period } => ({
|
||||
period: parsePeriod(search["period"]),
|
||||
}),
|
||||
loaderDeps: ({ search }): { period: Period } => ({ period: search.period ?? DEFAULT_PERIOD }),
|
||||
/**
|
||||
* Started here, awaited nowhere. Every panel reads these with `useQuery` and
|
||||
* owns its own loading and error surface, so awaiting would trade that whole
|
||||
* contract for one blocking navigation: the page would sit on the slowest of
|
||||
* five requests and then appear complete, instead of the four that answered
|
||||
* rendering beside the one still in flight. The rejections are caught only to
|
||||
* keep them from going unhandled; the panels state them.
|
||||
*/
|
||||
loader: ({ context, deps }) => {
|
||||
const start = (promise: Promise<unknown>) => void promise.catch(() => {});
|
||||
start(context.queryClient.ensureQueryData(healthQuery()));
|
||||
start(context.queryClient.ensureQueryData(statsQuery(deps.period)));
|
||||
start(context.queryClient.ensureQueryData(timeseriesQuery(deps.period)));
|
||||
start(context.queryClient.ensureQueryData(statsClientsQuery(deps.period)));
|
||||
// The registered names the client chart labels its series with. Started here
|
||||
// so the lookup is not a second round trip after the page chunk lands.
|
||||
start(context.queryClient.ensureQueryData(clientsQuery()));
|
||||
start(context.queryClient.ensureQueryData(statsTypesQuery(deps.period)));
|
||||
start(context.queryClient.ensureQueryData(statsRoutesQuery(deps.period)));
|
||||
},
|
||||
component: lazyRouteComponent(() => import("@/features/overview/OverviewPage")),
|
||||
});
|
||||
|
||||
const liveRoute = createRoute({
|
||||
/**
|
||||
* Activity. The URL is the applied state: mode, the five filters, and nothing
|
||||
* else. Everything is validated by `activity/search.ts`, so a hand-typed or
|
||||
* stale parameter becomes `undefined` here rather than reaching the API as a
|
||||
* value it answers 400 to.
|
||||
*/
|
||||
const activityRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/live",
|
||||
component: lazyRouteComponent(() => import("@/features/live/LiveLogPage")),
|
||||
path: "/activity",
|
||||
validateSearch: validateActivitySearch,
|
||||
// An explicit projection, not `search` itself: the router hands the loader
|
||||
// whatever else the URL carried, and an unknown key would make two
|
||||
// otherwise-identical loads look like different deps.
|
||||
loaderDeps: ({ search }): ActivitySearch => ({
|
||||
mode: search.mode,
|
||||
since: search.since,
|
||||
until: search.until,
|
||||
domain: search.domain,
|
||||
client: search.client,
|
||||
blocked: search.blocked,
|
||||
}),
|
||||
/**
|
||||
* Starts the first page in parallel with the component chunk, and does not
|
||||
* wait for it. Awaiting would make every Apply a blocking navigation, which
|
||||
* throws away the `keepPreviousData` placeholder the list is built on: the
|
||||
* reader would lose the rows they were reading to a pending page instead of
|
||||
* watching them be replaced. The page owns the loading and error surfaces,
|
||||
* so the rejection is caught here only to keep it from going unhandled.
|
||||
*
|
||||
* Live mode reads the SSE stream and nothing else. Prefetching the log for
|
||||
* it would spend a request per navigation on rows the page never renders,
|
||||
* with the retained filters attached to make it look deliberate.
|
||||
*/
|
||||
loader: ({ context, deps }) => {
|
||||
if (deps.mode !== "history") return;
|
||||
void context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(queriesFilterOf(deps))).catch(() => {});
|
||||
},
|
||||
component: lazyRouteComponent(() => import("@/features/activity/ActivityPage")),
|
||||
});
|
||||
|
||||
/**
|
||||
* One logged query. Its search is the Activity search the reader arrived from,
|
||||
* validated by the same functions, so the back link and every related action
|
||||
* restore the exact investigation instead of a default view of it.
|
||||
*/
|
||||
const activityDetailRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/activity/queries/$id",
|
||||
validateSearch: validateActivitySearch,
|
||||
// Swallowed on purpose, as the diagnostics detail route does: a row
|
||||
// retention has pruned is a 404 the page explains, with the way back to the
|
||||
// log. The whole-page error component would call it a request failure.
|
||||
loader: ({ context, params }) =>
|
||||
context.queryClient.ensureQueryData(queryDetailQuery(Number(params.id))).catch(() => undefined),
|
||||
component: lazyRouteComponent(() => import("@/features/activity/ActivityDetailPage")),
|
||||
});
|
||||
|
||||
/** `domain` prefills and runs the simulation, so a query detail can link into it. */
|
||||
const activityTestRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/activity/test",
|
||||
validateSearch: (search: Record<string, unknown>): { domain?: string } => ({
|
||||
domain: validateText(search["domain"]),
|
||||
}),
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
|
||||
component: lazyRouteComponent(() => import("@/features/activity/PolicyTestPage")),
|
||||
});
|
||||
|
||||
const clientsRoute = createRoute({
|
||||
@@ -207,11 +309,61 @@ const upstreamsRoute = createRoute({
|
||||
component: lazyRouteComponent(() => import("@/features/upstreams/UpstreamsPage")),
|
||||
});
|
||||
|
||||
const lookupRoute = createRoute({
|
||||
/**
|
||||
* The filters and the window live in the url so an episode can be linked to as
|
||||
* it was read — a query detail links here with an absolute five-minute window
|
||||
* around one query, which only means anything if the page applies it. Anything
|
||||
* else in the search object is dropped: an unknown value would reach the api as
|
||||
* a query parameter the handler rejects with a 400.
|
||||
*/
|
||||
const diagnosticsRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/lookup",
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
|
||||
component: lazyRouteComponent(() => import("@/features/lookup/LookupPage")),
|
||||
path: "/diagnostics",
|
||||
validateSearch: (search: Record<string, unknown>): DiagnosticsSearch => {
|
||||
const state = search["state"];
|
||||
const severity = search["severity"];
|
||||
return {
|
||||
state: state === "active" || state === "resolved" ? state : undefined,
|
||||
severity: severity === "warning" || severity === "error" ? severity : undefined,
|
||||
component: validateText(search["component"]),
|
||||
since: validateTimestamp(search["since"]),
|
||||
until: validateTimestamp(search["until"]),
|
||||
};
|
||||
},
|
||||
loaderDeps: ({ search }): DiagnosticsSearch => ({
|
||||
state: search.state,
|
||||
severity: search.severity,
|
||||
component: search.component,
|
||||
since: search.since,
|
||||
until: search.until,
|
||||
}),
|
||||
/**
|
||||
* Started, not awaited, as Overview's is: the strip and the two lists each
|
||||
* render their own loading and error state, and the health strip's whole
|
||||
* contract begins with a visible loading state it would never reach if the
|
||||
* route held the page back until the reading arrived.
|
||||
*/
|
||||
loader: ({ context, deps }) => {
|
||||
const base = diagnosticsFilterOf(deps);
|
||||
void context.queryClient.ensureQueryData(healthQuery()).catch(() => {});
|
||||
for (const state of ["active", "resolved"] as const) {
|
||||
void context.queryClient
|
||||
.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state }))
|
||||
.catch(() => {});
|
||||
}
|
||||
},
|
||||
component: lazyRouteComponent(() => import("@/features/diagnostics/DiagnosticsPage")),
|
||||
});
|
||||
|
||||
const diagnosticDetailRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/diagnostics/$id",
|
||||
// Swallowed on purpose: an event retention has removed is a 404 the page
|
||||
// itself explains, with the way back to the list. The whole-page error
|
||||
// component would state it as a request failure instead.
|
||||
loader: ({ context, params }) =>
|
||||
context.queryClient.ensureQueryData(diagnosticQuery(Number(params.id))).catch(() => undefined),
|
||||
component: lazyRouteComponent(() => import("@/features/diagnostics/DiagnosticDetailPage")),
|
||||
});
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
@@ -224,16 +376,19 @@ const settingsRoute = createRoute({
|
||||
const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
shellRoute.addChildren([
|
||||
dashboardRoute,
|
||||
queriesRoute,
|
||||
liveRoute,
|
||||
indexRoute,
|
||||
overviewRoute,
|
||||
activityRoute,
|
||||
activityDetailRoute,
|
||||
activityTestRoute,
|
||||
clientsRoute,
|
||||
groupsRoute,
|
||||
blocklistsRoute,
|
||||
rulesRoute,
|
||||
localDnsRoute,
|
||||
upstreamsRoute,
|
||||
lookupRoute,
|
||||
diagnosticsRoute,
|
||||
diagnosticDetailRoute,
|
||||
settingsRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
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, resetAuthProbeForTests } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { formatClock } from "@/lib/format";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Health } from "@/lib/types";
|
||||
|
||||
const NAV_LABELS = [
|
||||
"Dashboard",
|
||||
"Query Log",
|
||||
"Live",
|
||||
"Overview",
|
||||
"Activity",
|
||||
"Clients",
|
||||
"Groups",
|
||||
"Blocklists",
|
||||
"Rules",
|
||||
"Local DNS",
|
||||
"Upstreams",
|
||||
"Lookup",
|
||||
"Diagnostics",
|
||||
"Settings",
|
||||
];
|
||||
|
||||
@@ -26,31 +28,62 @@ const RESPONSES: Record<string, unknown> = {
|
||||
until: 86400,
|
||||
queries: 0,
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
clients: 0,
|
||||
avg_response_time_us: null,
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
"/api/stats/timeseries?period=24h": { period: "24h", since: 0, until: 86400, bucket_seconds: 1800, buckets: [] },
|
||||
"/api/health": {
|
||||
status: "ok",
|
||||
disk: { state: "ok", free_bytes: 0, db_bytes: 0, log_bytes: 0, sample_failures: 0 },
|
||||
upstreams: { available: 1, total: 1 },
|
||||
queries_dropped: 0,
|
||||
writer_failed: false,
|
||||
refreshes_gated: 0,
|
||||
snapshot_generation: null,
|
||||
"/api/stats/timeseries?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
bucket_seconds: 1800,
|
||||
buckets: [],
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
"/api/upstream/health": { upstreams: [], available: 1, total: 1 },
|
||||
"/api/stats/clients?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
bucket_seconds: 1800,
|
||||
clients: [],
|
||||
other: [],
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
"/api/stats/types?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
types: [],
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
"/api/stats/routes?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
routes: [],
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
"/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } },
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
resetAuthProbeForTests();
|
||||
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */
|
||||
let healthBody: Health | null;
|
||||
|
||||
function stubFetch(extra: (url: string) => Response | null = () => null) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const override = extra(url);
|
||||
if (override !== null) return override;
|
||||
if (url === "/api/health") {
|
||||
const failed = healthBody === null;
|
||||
return new Response(JSON.stringify(failed ? { error: "health unavailable" } : healthBody), {
|
||||
status: failed ? 503 : 200,
|
||||
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), {
|
||||
@@ -59,13 +92,9 @@ beforeEach(() => {
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("shell renders the dashboard route with all nav links", async () => {
|
||||
function renderShell() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||
render(
|
||||
@@ -75,8 +104,30 @@ test("shell renders the dashboard route with all nav links", async () => {
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
function diagnosticsBadgeText(): string | null {
|
||||
const link = screen.getAllByRole("link", { name: /^Diagnostics/ })[0];
|
||||
const badge = link.querySelector("[aria-label]");
|
||||
return badge === null ? null : (badge.textContent ?? "");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
resetAuthProbeForTests();
|
||||
healthBody = health();
|
||||
stubFetch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("shell renders the overview route with all nav links", async () => {
|
||||
renderShell();
|
||||
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
|
||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||
expect(nav).toBeTruthy();
|
||||
@@ -86,41 +137,105 @@ test("shell renders the dashboard route with all nav links", async () => {
|
||||
});
|
||||
|
||||
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/auth/login")
|
||||
return new Response(JSON.stringify({ error: "password required" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
if (url === "/api/auth/logout")
|
||||
return new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "retry-after": "7" },
|
||||
});
|
||||
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,
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/auth/login")
|
||||
return new Response(JSON.stringify({ error: "password required" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (url === "/api/auth/logout")
|
||||
return new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "retry-after": "7" },
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
renderShell();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
||||
|
||||
await screen.findByText("Rate limited. Try again in 7s.");
|
||||
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the header carries no protection display at all any more", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
for (const gone of [/^Protection/, /^Paused/]) {
|
||||
expect(screen.queryByRole("link", { name: gone })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("Pause sits at the foot of the sidebar, above the version label", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
const aside = document.querySelector("aside") as HTMLElement;
|
||||
const pause = await waitFor(() => within(aside).getByRole("button", { name: "Pause" }));
|
||||
const version = within(aside).getByText(/^nxdns v/);
|
||||
// Node order, not styling: the control precedes the version footer.
|
||||
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the mobile drawer carries the same control, not a header one it lost", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(1));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
|
||||
|
||||
// Both renderings are mounted; the viewport decides which is painted.
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(2));
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
const pause = within(drawer).getByRole("button", { name: "Pause" });
|
||||
const version = within(drawer).getByText(/^nxdns v/);
|
||||
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
|
||||
// The trace a pause leaves on every page. With the header indicator and the
|
||||
// Overview status row both gone, a reader who is not on Diagnostics has only
|
||||
// this line to tell them filtering is off.
|
||||
const until = Math.floor(Date.now() / 1000) + 90;
|
||||
healthBody = health({ protection: { state: "paused", until } });
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
|
||||
const aside = document.querySelector("aside") as HTMLElement;
|
||||
await waitFor(() => expect(within(aside).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
await waitFor(() => expect(within(drawer).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
|
||||
});
|
||||
|
||||
test("nothing open and a healthy rollup leaves the Diagnostics item unbadged", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
await waitFor(() => expect(document.querySelector("aside")?.textContent).toContain("Diagnostics"));
|
||||
expect(diagnosticsBadgeText()).toBeNull();
|
||||
});
|
||||
|
||||
test("open episodes are counted on the nav item", async () => {
|
||||
healthBody = health({ diagnostics: { state: "recording", active_warnings: 1, active_errors: 2 } });
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
await waitFor(() => expect(diagnosticsBadgeText()).toBe("3"));
|
||||
expect(screen.getAllByLabelText("3 active diagnostic events").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("a degraded rollup with nothing open is still marked, and a failed poll too", async () => {
|
||||
healthBody = health({ status: "degraded" });
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
|
||||
expect(screen.getAllByLabelText("Health degraded").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("a health poll that failed is marked unknown rather than left looking healthy", async () => {
|
||||
healthBody = null;
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
|
||||
expect(screen.getAllByLabelText("Health unavailable").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -4,8 +4,9 @@ import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useAuth } from "@/auth/store";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { versionQuery } from "@/lib/queries";
|
||||
import PauseWidget from "../features/pause/PauseWidget";
|
||||
import { healthQuery, versionQuery } from "@/lib/queries";
|
||||
import PauseControl from "@/features/pause/PauseControl";
|
||||
import { diagnosticsBadge } from "./diagnosticsBadge";
|
||||
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
|
||||
import RestartBanner from "../features/settings/RestartBanner";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
@@ -16,16 +17,15 @@ const WIDE = "@media (min-width: 768px)";
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard" },
|
||||
{ to: "/queries", label: "Query Log" },
|
||||
{ to: "/live", label: "Live" },
|
||||
{ to: "/overview", label: "Overview" },
|
||||
{ to: "/activity", label: "Activity" },
|
||||
{ to: "/clients", label: "Clients" },
|
||||
{ to: "/groups", label: "Groups" },
|
||||
{ to: "/blocklists", label: "Blocklists" },
|
||||
{ to: "/rules", label: "Rules" },
|
||||
{ to: "/local-dns", label: "Local DNS" },
|
||||
{ to: "/upstreams", label: "Upstreams" },
|
||||
{ to: "/lookup", label: "Lookup" },
|
||||
{ to: "/diagnostics", label: "Diagnostics" },
|
||||
{ to: "/settings", label: "Settings" },
|
||||
] as const;
|
||||
|
||||
@@ -36,12 +36,40 @@ const styles = stylex.create({
|
||||
gap: "0.25rem",
|
||||
},
|
||||
navLink: {
|
||||
display: "block",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
navLabel: {
|
||||
flex: 1,
|
||||
},
|
||||
/**
|
||||
* Neutral chrome: the mark is the message, and a coloured pill here would be
|
||||
* the page's loudest element on every route. Text and shape carry it.
|
||||
*/
|
||||
badge: {
|
||||
minWidth: "1.25rem",
|
||||
borderRadius: "0.625rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceHover,
|
||||
paddingInline: "0.375rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1.125rem",
|
||||
fontWeight: 500,
|
||||
textAlign: "center",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** The control sits with the footer, not in the scrolling nav list above it. */
|
||||
sidebarFooter: {
|
||||
paddingInline: "1rem",
|
||||
paddingTop: "0.75rem",
|
||||
},
|
||||
/** The current page reads as a filled chip, heavier than the hover fill. */
|
||||
navActive: {
|
||||
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||
@@ -136,6 +164,8 @@ const styles = stylex.create({
|
||||
});
|
||||
|
||||
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const health = useQuery(healthQuery());
|
||||
const badge = diagnosticsBadge(health.data, health.isError);
|
||||
return (
|
||||
<ul {...stylex.props(styles.navList)}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
@@ -143,7 +173,6 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
<Link
|
||||
to={item.to}
|
||||
onClick={onNavigate}
|
||||
activeOptions={{ exact: item.to === "/" }}
|
||||
activeProps={{
|
||||
"aria-current": "page",
|
||||
className: stylex.props(styles.navActive).className,
|
||||
@@ -151,7 +180,12 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
inactiveProps={{ className: stylex.props(styles.navIdle).className }}
|
||||
{...stylex.props(styles.navLink, shared.focusRing)}
|
||||
>
|
||||
{item.label}
|
||||
<span {...stylex.props(styles.navLabel)}>{item.label}</span>
|
||||
{item.to === "/diagnostics" && badge !== null && (
|
||||
<span aria-label={badge.label} {...stylex.props(styles.badge)}>
|
||||
{badge.text}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
@@ -159,6 +193,22 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's foot, in both renderings. Pause is a runtime action on the whole
|
||||
* resolver rather than on the page in front of the reader, which is why it sits
|
||||
* with the version label instead of in the header of every route.
|
||||
*/
|
||||
function SidebarFooter() {
|
||||
return (
|
||||
<>
|
||||
<div {...stylex.props(styles.sidebarFooter)}>
|
||||
<PauseControl />
|
||||
</div>
|
||||
<VersionFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionFooter() {
|
||||
const { data } = useQuery(versionQuery());
|
||||
return (
|
||||
@@ -202,7 +252,7 @@ export default function AppShell() {
|
||||
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
|
||||
<NavLinks />
|
||||
</nav>
|
||||
<VersionFooter />
|
||||
<SidebarFooter />
|
||||
</aside>
|
||||
<div {...stylex.props(styles.column)}>
|
||||
<header {...stylex.props(styles.header)}>
|
||||
@@ -217,7 +267,6 @@ export default function AppShell() {
|
||||
</button>
|
||||
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
|
||||
<div {...stylex.props(styles.headerRight)}>
|
||||
<PauseWidget />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
@@ -228,7 +277,7 @@ export default function AppShell() {
|
||||
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
|
||||
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
||||
</nav>
|
||||
<VersionFooter />
|
||||
<SidebarFooter />
|
||||
</div>
|
||||
)}
|
||||
<main {...stylex.props(styles.main)}>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { diagnosticsBadge } from "./diagnosticsBadge";
|
||||
|
||||
test("a healthy box with nothing open wears no badge", () => {
|
||||
expect(diagnosticsBadge(health(), false)).toBeNull();
|
||||
});
|
||||
|
||||
test("open episodes are the count, warnings and errors together", () => {
|
||||
const badge = diagnosticsBadge(
|
||||
health({ diagnostics: { state: "recording", active_warnings: 2, active_errors: 1 } }),
|
||||
false,
|
||||
);
|
||||
expect(badge).toEqual({ text: "3", label: "3 active diagnostic events" });
|
||||
});
|
||||
|
||||
test("one open episode is counted in the singular", () => {
|
||||
const badge = diagnosticsBadge(
|
||||
health({ diagnostics: { state: "recording", active_warnings: 0, active_errors: 1 } }),
|
||||
false,
|
||||
);
|
||||
expect(badge).toEqual({ text: "1", label: "1 active diagnostic event" });
|
||||
});
|
||||
|
||||
test("a degraded rollup with no open episode still shows, so no degraded state is invisible", () => {
|
||||
const badge = diagnosticsBadge(health({ status: "degraded" }), false);
|
||||
expect(badge).toEqual({ text: "!", label: "Health degraded" });
|
||||
});
|
||||
|
||||
test("a failed poll is not evidence of health, badge and all", () => {
|
||||
// Cached body says everything is fine; the poll that would have confirmed it
|
||||
// never landed. An unbadged item here claims health on no evidence.
|
||||
expect(diagnosticsBadge(health(), true)).toEqual({ text: "!", label: "Health unavailable" });
|
||||
expect(diagnosticsBadge(undefined, true)).toEqual({ text: "!", label: "Health unavailable" });
|
||||
});
|
||||
|
||||
test("the first poll being in flight is the one unknown that hides", () => {
|
||||
expect(diagnosticsBadge(undefined, false)).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The count beside the Diagnostics nav item.
|
||||
*
|
||||
* Three things have to be visible and only one of them is a number. Open
|
||||
* episodes are the count. A `degraded` rollup with no open episode still has to
|
||||
* show something, or a degraded box looks exactly like a healthy one. And a
|
||||
* health poll that failed is not evidence of health: it shows the same neutral
|
||||
* mark, because the alternative is an unbadged item claiming all is well on no
|
||||
* evidence at all.
|
||||
*
|
||||
* "Fresh" here means the latest poll succeeded, never TanStack's `isStale`:
|
||||
* healthQuery's `staleTime` is 0, so staleness is true in every gap between
|
||||
* polls and would badge the item permanently.
|
||||
*/
|
||||
|
||||
import type { Health } from "@/lib/types";
|
||||
|
||||
export interface NavBadge {
|
||||
/** What the badge shows. Shape and text, never colour alone. */
|
||||
text: string;
|
||||
/** What a screen reader hears in its place. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function diagnosticsBadge(health: Health | undefined, pollFailed: boolean): NavBadge | null {
|
||||
// The one hidden unknown, and only because it is momentary: the first poll has
|
||||
// not answered yet, and there is nothing to be right or wrong about.
|
||||
if (health === undefined && !pollFailed) return null;
|
||||
if (pollFailed) return { text: "!", label: "Health unavailable" };
|
||||
if (health === undefined) return null;
|
||||
const open = health.diagnostics.active_warnings + health.diagnostics.active_errors;
|
||||
if (open > 0) {
|
||||
return { text: String(open), label: `${open} active diagnostic ${open === 1 ? "event" : "events"}` };
|
||||
}
|
||||
if (health.status === "degraded") return { text: "!", label: "Health degraded" };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import Select from "./Select";
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
];
|
||||
|
||||
/** RAC opens a Select from the keyboard as readily as from a pointer. */
|
||||
function open(trigger: HTMLElement) {
|
||||
fireEvent.keyDown(trigger, { key: "Enter" });
|
||||
fireEvent.keyUp(trigger, { key: "Enter" });
|
||||
}
|
||||
|
||||
test("a disabled select keeps its value on screen but takes no input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Select label="Status" options={OPTIONS} value="blocked" onChange={onChange} isDisabled />);
|
||||
|
||||
const trigger = screen.getByRole("button");
|
||||
expect(trigger.textContent).toContain("Blocked only");
|
||||
// A disabled button is out of the tab order by definition, so the filter row
|
||||
// cannot be reached by keyboard while live mode owns it.
|
||||
expect(trigger).toHaveProperty("disabled", true);
|
||||
|
||||
open(trigger);
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("an enabled select still opens", () => {
|
||||
render(<Select label="Status" options={OPTIONS} value="any" onChange={vi.fn()} />);
|
||||
open(screen.getByRole("button"));
|
||||
expect(screen.getByRole("listbox")).toBeTruthy();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user