Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79dd0bbcb
|
||
|
|
039eeeda96
|
||
|
|
c65d92d8f8
|
||
|
|
207252acee
|
||
|
|
f1de80477a
|
||
|
|
3c674966be
|
||
|
|
59d6be98f8
|
||
|
|
317d5dd4f8
|
||
|
|
79578e1d2a
|
||
|
|
d5613ee718
|
||
|
|
09932b2d84
|
||
|
|
272655f60c
|
||
|
|
dd5a9f6ab8
|
||
|
|
72cdbbd113
|
||
|
|
6170571d23
|
||
|
|
c8470724ce
|
||
|
|
7cdb74ec17
|
||
|
|
4367003788
|
||
|
|
728b8d64e1
|
||
|
|
091962a996
|
||
|
|
4fcbd0ac12
|
||
|
|
d961b152a3
|
||
|
|
17e6e93ce1
|
||
|
|
8a44d901d9
|
@@ -32,6 +32,23 @@ Any *other* failure text is real. Trust the summary line: `zig build test` exiti
|
||||
|
||||
One trap: running a cached test binary by hand with `--listen=-` aborts with `internal test runner failure: EndOfStream`. That is not a teardown bug; the IPC runner is talking to a closed stdin because no build runner is on the other end. Run the binary with no arguments to get the plain stdio report.
|
||||
|
||||
## Debug-mode miscompile: a `bool` live across an atomic read-modify-write
|
||||
|
||||
In Debug the x86_64 self-hosted backend is the default, and zig 0.16.0's atomic read-modify-write lowering there does not invalidate a `bool` the register allocator is still tracking in EFLAGS. The `bool` silently becomes the flags the `lock xadd` left behind. Release modes go through LLVM and are unaffected, so this can only ever break `zig build test`, never a shipped binary.
|
||||
|
||||
The shape to avoid is a comparison whose result stays live across `fetchAdd`/`fetchSub`/`@atomicRmw` and is then branched on:
|
||||
|
||||
```zig
|
||||
const idle = old.refs == 0; // sete 0x50(%rsp) -- correct
|
||||
_ = self.published.fetchAdd(1, .monotonic); // lock xadd %rdi,(%rsi)
|
||||
// sete 0x51(%rsp) -- bogus, reads EFLAGS from the xadd
|
||||
return if (idle) old else null; // branches on 0x51, not 0x50
|
||||
```
|
||||
|
||||
That function returns `null` for every input. `fetchAdd` is the only trigger: a plain `+= 1`, an atomic `load`, and an atomic `store` in the same slot all compile correctly, and inserting any call (including `std.debug.print`) between the comparison and the branch forces a spill that hides it. Build the same file with `-fllvm` or `-OReleaseSafe` to confirm a suspected instance.
|
||||
|
||||
`Owner.published` in `src/upstream/owner.zig` is a plain `u64` under the owner's mutex for this reason. Do not "modernize" it to `std.atomic.Value(u64)`.
|
||||
|
||||
## Regenerating the contract samples
|
||||
|
||||
`admin/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
|
||||
|
||||
@@ -4,6 +4,60 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
|
||||
|
||||
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
|
||||
|
||||
## [0.0.14] - 2026-08-28
|
||||
|
||||
Schema changes stop costing you your query history. querylog.db is now version-stamped and migrated in place; the server refuses to start rather than ever reset a healthy file, and the release tooling refuses to ship a schema change that is neither migratable nor explicitly disclosed with recovery steps. Three releases (0.0.6, 0.0.9, 0.0.12) each discarded the log on upgrade; this ends that.
|
||||
|
||||
### Changed
|
||||
|
||||
- **querylog.db is migrated in place.** The file now carries a schema version, and a release that changes the schema ships a migration that runs at startup: one consistent backup (`querylog.db.pre-migrate-<timestamp>`, mode 0600, only the most recent kept), then every step and the version stamp in a single transaction. A failure before the commit rolls back and leaves your file exactly as it was.
|
||||
- **The server refuses instead of resetting.** A querylog.db it cannot use — newer than the binary, older than 0.0.12, or mid-migration failure — is left untouched and the server exits with a clear message instead of setting the file aside and starting an empty log. The exit code (2) tells systemd not to restart-loop a deliberate refusal. Corruption is the only case that still sets a file aside automatically.
|
||||
- **The release gate now enforces the contract.** A schema change cannot be tagged unless it either ships a working migration (proven in CI against a frozen fixture of the previous schema, with shipped migration files locked byte-for-byte once released) or explicitly declares a break — which requires a version bump the server refuses on, a reset disclosure, and step-by-step restore instructions in this file.
|
||||
|
||||
**One hazard to know when downgrading.** The first start under this release restamps querylog.db from the old fingerprint to version 1 (contents untouched). If you later downgrade to 0.0.13 or older, that binary treats the new stamp as a schema mismatch, moves your file aside as `querylog.db.schema-changed-<timestamp>`, and starts an empty log. To recover: return to 0.0.14 or newer, stop the server, move the empty `querylog.db` away and delete its `querylog.db-wal` and `querylog.db-shm` files (leaving them would corrupt the restored file), rename the `.schema-changed-<timestamp>` file back to `querylog.db`, and start.
|
||||
|
||||
## [0.0.13] - 2026-08-27
|
||||
|
||||
The upstream query budget becomes one honest deadline. A busy network no longer blames a healthy standby for running out of time, and a query burst no longer queues invisibly until everything answers SERVFAIL at once.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The per-query upstream budget is now one absolute deadline, spent by everything that blocks.** Waiting for a free slot on a saturated upstream now spends the query's `upstream.total_timeout_ms` budget just like the exchange itself, instead of being invisible to it — under a burst, queries used to wait out their whole budget in the queue and then start attempts they could never finish. An attempt near the end of the budget runs truncated, and when a truncated attempt runs out of time that is evidence about the budget, not the upstream: it no longer counts against that upstream's health or success rate, and the query log no longer names an upstream that was given no fair chance. A field incident produced 279 rows blaming a standby whose health counters read zero for zero; those rows now attribute nothing.
|
||||
- **A query no longer blocks behind a saturated upstream while another has capacity.** Admission sweeps the upstreams in priority order and takes the first free slot; priority now means the order among upstreams that can be admitted right now, and a query blocks only when nothing has capacity — on the highest-priority eligible upstream, bounded by the remaining budget.
|
||||
- **Conditional forward zones spend `upstream.read_timeout_ms` once per query.** A UDP attempt, a truncated answer and the TCP retry now share the one budget instead of taking a fresh one each, so a slow zone resolver can no longer stretch a single query to several times the configured timeout.
|
||||
|
||||
### Added
|
||||
|
||||
- **`nxdns_upstream_budget_exhausted_total`.** A pool-wide counter of queries whose budget ran out — in the queue or mid-attempt — before any upstream answered. It carries no per-upstream label on purpose: running out of budget is a fact about the pool.
|
||||
- **A configuration with more than 64 enabled upstreams is rejected at validation** with a clear message, instead of tripping an internal limit at startup.
|
||||
|
||||
## [0.0.12] - 2026-08-27
|
||||
|
||||
Overview stops re-reading the whole query log. One endpoint, one snapshot, pre-aggregated buckets — a 30-day view now costs the same on a month of history as on a day of it. Read the upgrade note first: it resets your query history.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Upgrading resets your query history.** The query-log schema gains the aggregate tables described below, and `querylog.db` is never migrated: the first start after the upgrade sets the old file aside (kept on disk next to the new one, named with the reason) and begins a fresh log. Settings, groups, blocklists and every other configuration are untouched.
|
||||
- **The Overview is served by one endpoint, `GET /api/overview`.** It replaces `GET /api/stats`, `/api/stats/timeseries`, `/api/stats/types`, `/api/stats/routes` and `/api/stats/clients`, which are gone. The five panels now come from a single database snapshot, so they can no longer disagree with each other, and the page shows one loading and one error state instead of five.
|
||||
- **Query statistics are pre-aggregated as they are written.** The query log now maintains 30-minute aggregate tables in the same transaction that stores the rows, and the 24-hour, 7-day and 30-day views read those instead of scanning every logged query. The cost of opening the Overview no longer grows with the size of the log: measured at three million rows, the 30-day view went from roughly eight-tenths of a second of scanning to under fifty milliseconds, at the price of about ten percent on each background write batch and ~1.5 MB of disk. The server also keeps the most recent response per period in memory and serves repeat polls from it while nothing has changed — until new queries land, retention prunes, or the period's time window rolls forward — so on a quiet network most of the steady 30-second refreshes do no database work at all.
|
||||
|
||||
The Overview charts move to visx and grow up: one hover treatment across all four, honest labels, and maintained d3 math under the app's own rendering.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The Overview charts are drawn with visx.** The hand-written chart layout code is replaced by visx 4.0.0 primitives — maintained d3 math for the scales, ticks, stacking and arcs — while the rendering, colours and themes stay the app's own. The charts read as before, with four behaviour improvements: all four charts now share the same hover treatment (the per-client chart and both donuts gain the tooltip and dimming the query timeline already had, so pointing at a ring segment names it, its count and its share), an open tooltip follows a data refresh instead of showing stale counts, and it retires cleanly when the time window rolls. The admin bundle grows by about 68 KB and stays under its size budget.
|
||||
- **The query timeline's third series is called "Allowed".** What the chart called "Other" is every query that was neither blocked nor served from cache — answered upstream, from a local record or a forward zone — so it is now named for what it is rather than for the subtraction that produces it. It stays on the chart even in a window where nothing was allowed, alongside Blocked and Cached: all three name a kind of answer a query can get, and a period where every query was blocked or cached is worth seeing.
|
||||
- **The client chart drops "Other" in a window where it counted nothing.** That series aggregates the clients outside the top eight, so when it counts nothing there is nothing being aggregated, and a legend entry, a tooltip row and a table column that exist only to say "zero" are noise. The named clients stay even at zero, because a client that went quiet is a fact about the window.
|
||||
|
||||
## [0.0.10] - 2026-08-24
|
||||
|
||||
Configuration goes live: when the database owns the configuration, saving a setting reconfigures the running process instead of asking for a restart. The restart-required set shrinks to the listen sockets and the admin interface switch.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Almost every settings change now applies while the server runs.** When the database owns the configuration, saving a setting takes effect immediately — the blocking response, upstream timeouts, cache size, rate limits, session lifetime, log level and destination, blocklist update schedule, privacy flags, disk thresholds and the query-log buffer all reconfigure the running process, exactly as Pi-hole and AdGuard Home do. Nothing is written to the database unless the running server already accepted it, so the API can never report a value the process refused. The restart-required set shrinks from every scalar key to the twelve that genuinely need one: listen addresses and ports, and turning the admin interface itself on or off. The admin pages drop their restart notices for everything else, and an upstream edit — the loudest offender — now applies to the next query. A configuration file still works the way it always has: edit the file, restart the process.
|
||||
- **The release cut refuses to ship an undisclosed query-log schema change.** `zig build cut` now compares the `querylog.db` schema fingerprint of the previous release tag against this tree's, and when they differ it requires the changelog section for the version being cut to state that the upgrade discards the stored query history. 0.0.9 changed the schema and its announcement did not mention it; the file is never migrated, so that upgrade silently threw every logged query away.
|
||||
|
||||
## [0.0.9] - 2026-08-22
|
||||
|
||||
Query provenance: every logged query becomes exactly explainable — what the policy decided, what matched, where the answer came from and what the client saw. The handler records all of it as the reply goes out, `query_log` stores it, and a detail page reads one query back in the order the pipeline decided it. Read the upgrade note below first: it resets your query history.
|
||||
|
||||
@@ -85,13 +85,13 @@ Verified: 0.16.0 ships `std.crypto.tls.Client` only. There is no server-side TLS
|
||||
Two SQLite files with opposite write profiles, isolated from each other:
|
||||
|
||||
- **`config.db`** — small, precious, rarely written: groups, clients, prefixes, upstreams, blocklist source metadata, rules, local records, forward zones, settings, schema version.
|
||||
- **`querylog.db`** — high-churn, large, expendable: query log + its own private `domains` dimension table. Client identity stored as **IP text**, not a FK into config — log rows are immutable facts and must not point at mutable config rows. If `querylog.db` is missing or corrupt at startup, rename aside, recreate, keep serving. Log loss is not an outage.
|
||||
- **`querylog.db`** — high-churn, large, expendable: query log + its own private `domains` dimension table. Client identity stored as **IP text**, not a FK into config — log rows are immutable facts and must not point at mutable config rows. If `querylog.db` is missing or corrupt at startup, rename aside, recreate, keep serving — corruption only; a healthy file whose schema this build cannot use refuses the startup instead (§3.7).
|
||||
- No cross-DB references. Retention/VACUUM churn never touches `config.db`; config backup is a copy of a tiny file.
|
||||
|
||||
### 3.7 Upgrades: Auto-Migration (Decision J)
|
||||
|
||||
- `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart. Before v0.1 the list holds one step — the baseline of §11.2, edited in place — because nxdns has no installs and a step exists only to reconcile a database somebody already has.
|
||||
- `querylog.db`: **no migrations.** On schema mismatch: rename aside, recreate fresh.
|
||||
- `querylog.db`: a logical version in `PRAGMA user_version`, migrated **in place** at startup by the same shape of compiled step list, inside one transaction and behind one `querylog.db.pre-migrate-<epoch>` backup (only the newest is kept). A healthy file is never renamed aside: a version this build cannot reach refuses the startup with instructions, and only corruption recreates. A deliberate break is still allowed, but it must be versioned, refused at startup, and disclosed in the changelog — the cut gate enforces that. See `docs/reference/query-log-lifecycle.md`.
|
||||
|
||||
### 3.8 Blocklist Storage (Decision A)
|
||||
|
||||
@@ -238,7 +238,7 @@ src/
|
||||
web/
|
||||
server.zig router.zig auth.zig sse.zig static.zig metrics.zig openapi.zig
|
||||
handlers/
|
||||
auth.zig stats.zig queries.zig clients.zig groups.zig blocklists.zig
|
||||
auth.zig overview.zig queries.zig clients.zig groups.zig blocklists.zig
|
||||
rules.zig local.zig lookup.zig pause.zig settings.zig
|
||||
upstream_health.zig certs.zig health.zig version.zig
|
||||
|
||||
@@ -484,6 +484,8 @@ CREATE INDEX idx_query_log_client ON query_log(client_ip);
|
||||
CREATE INDEX idx_query_log_domain ON query_log(domain_id);
|
||||
```
|
||||
|
||||
The sketch above is the original shape; `src/storage/querylog_schema.zig` is the authority, and the provenance columns milestone 28 added are not repeated here. Beside the raw rows the file carries four projection tables — `bucket_totals`, `bucket_clients`, `bucket_types`, `bucket_routes` — on a 30-minute grain, which is what `GET /api/overview` reads for the 24h, 7d and 30d windows instead of scanning every row. They are maintained by the batch writer and by retention inside the same transaction as the raw rows, so SQLite's transaction is the whole coherence story: no second file, no backfill, no rebuild command. The 1h window is narrower than the grain and takes one raw scan.
|
||||
|
||||
### 11.4 Query Logger
|
||||
|
||||
- In-memory buffer, mutex guarded, hard cap `query_log_buffer_max` (default 10000).
|
||||
@@ -493,7 +495,7 @@ CREATE INDEX idx_query_log_domain ON query_log(domain_id);
|
||||
|
||||
### 11.5 Retention
|
||||
|
||||
Periodic delete of rows older than `retention_days`; scheduled checkpoint/VACUUM on `querylog.db` only.
|
||||
Periodic delete of rows older than `retention_days`, dropping the projection buckets behind the cutoff and recomputing the straddling one in the same transaction; scheduled checkpoint/VACUUM on `querylog.db` only.
|
||||
|
||||
### 11.6 Disk Discipline (cloudflared lesson)
|
||||
|
||||
@@ -536,7 +538,7 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
|
||||
### 13.1 Endpoints
|
||||
|
||||
- `POST /api/auth/login`, `POST /api/auth/logout`
|
||||
- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…`, `GET /api/stats/types?period=…`, `GET /api/stats/routes?period=…`, `GET /api/stats/clients?period=…`
|
||||
- `GET /api/overview?period=…` — every Overview panel in one response over one read transaction
|
||||
- `GET /api/queries` (filter + paginate), `GET /api/queries/live` (SSE, per-IP cap)
|
||||
- `GET/PUT /api/clients/{id}`
|
||||
- `GET/POST/PUT/DELETE /api/groups…`, `/api/blocklists…`, `/api/rules…`, `/api/local-records…`, `/api/forward-zones…`
|
||||
@@ -664,7 +666,7 @@ The project publishes released binaries and container images from its own Gitea
|
||||
6. Disk-fill degrades gracefully; no silent log-flood failure mode.
|
||||
7. Web UI + API provide full admin functionality; OpenAPI contract tests green.
|
||||
8. `nxdns export` round-trips via `nxdns import`.
|
||||
9. Query logging, stats, SSE live stream work; querylog.db corruption self-heals.
|
||||
9. Query logging, the overview, SSE live stream work; querylog.db corruption self-heals.
|
||||
10. Local DoH + DoT endpoints serve LAN clients.
|
||||
11. Schema upgrade = install + restart (migration test proves it).
|
||||
12. All suites green in Gitea CI for both targets.
|
||||
@@ -692,5 +694,5 @@ The project publishes released binaries and container images from its own Gitea
|
||||
| G | SQLite vendored amalgamation + own thin wrapper |
|
||||
| H | Two DBs: `config.db` (precious) + `querylog.db` (expendable, self-contained, client IP as text) |
|
||||
| I | Frontend embedded in binary; dev flag serves from disk; static musl release builds |
|
||||
| J | Auto-migration for `config.db` at startup; `querylog.db` recreated on mismatch |
|
||||
| J | Auto-migration at startup for both databases; `querylog.db` recreated only when corrupt |
|
||||
| — | Safe-search per-group; Prometheus `/metrics` in scope; CI on self-hosted Gitea Actions |
|
||||
|
||||
Generated
+508
-3
@@ -11,6 +11,12 @@
|
||||
"@stylexjs/stylex": "0.19.0",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-router": "1.170.18",
|
||||
"@visx/axis": "4.0.0",
|
||||
"@visx/grid": "4.0.0",
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/shape": "4.0.0",
|
||||
"@visx/tooltip": "4.0.0",
|
||||
"react": "19.2.8",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "19.2.8"
|
||||
@@ -1619,6 +1625,84 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
|
||||
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
|
||||
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
|
||||
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
|
||||
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz",
|
||||
"integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -1633,6 +1717,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
@@ -1647,7 +1737,7 @@
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1657,7 +1747,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -2003,6 +2093,211 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/axis": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/axis/-/axis-4.0.0.tgz",
|
||||
"integrity": "sha512-cSPNO9Aic2UX49AmIeJJ9TQrrAkvUHpHOGqimUAXhbg07VI3HoHDKcXZdrjvQz+g0LhubrUkjj8Gwju2WZ3dtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/point": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/shape": "4.0.0",
|
||||
"@visx/text": "4.0.0",
|
||||
"classnames": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/bounds": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/bounds/-/bounds-4.0.0.tgz",
|
||||
"integrity": "sha512-3wAfN5fg2bxg/5f3MlKWzGDTKU2hdKkxq8bVWXpPZQV0UiVc0myuU1qa34c7tN3hg5SDe6MJi/bae2eTQDgEiQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/curve": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.0.tgz",
|
||||
"integrity": "sha512-bXrOSd2BzVCxR7kBE7gPTSU4plxzYN75w7erdHYabwn3vzP+xFk1o0gg3NOHEPJzt5tXqf0Ecm5YqHACOnipTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/vendor": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/grid": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.0.tgz",
|
||||
"integrity": "sha512-BUnznqYo8jyn3zXRWZiF0zwg1VQl6w5kYqZ+XMY+cPXtAHZZbwGXBChMpPefH5Lj1+P1b4Pf3QihKG8zjL9PCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/curve": "4.0.0",
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/point": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/shape": "4.0.0",
|
||||
"classnames": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/group": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.0.tgz",
|
||||
"integrity": "sha512-HGAq8BCqn5x0t94CJOLJeKtWss9LFpF3HY69HbuO1PlR+B9c4aVT9xbjfudmU8wGQZLT6JNt+PJvxbO2aJ+1aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/point": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.0.tgz",
|
||||
"integrity": "sha512-hwJ9UlIjg3iV4iiJ3RN5SNTXama+zGaNe57sXSrSvPiqhkDXypsYBdDSlAnHmKbMzHP0zFmgB1UJciB/QUyigw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@visx/scale": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.0.tgz",
|
||||
"integrity": "sha512-Q3VaSLKlsrxxSl9CwS2D3Mvz4/4kIp/6NMKRY8Im0Au1hrQh2egU806p0+JwN0ccuGGDe03jyAxX4BSRAH8Plg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/vendor": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/shape": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.0.tgz",
|
||||
"integrity": "sha512-X0FP3OFjQhc5/6Vj2aY7cK2JwKYI0H0hQM7cdNRWy/ZMMlrh5KpEdqx0HVo0KoTeTlkUvO+kydMLnAuuCRFIFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/curve": "4.0.0",
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/vendor": "4.0.0",
|
||||
"classnames": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/text": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/text/-/text-4.0.0.tgz",
|
||||
"integrity": "sha512-39f2goSaKy5Mqn89lRDGSJ1IMr49wplkbIFHsFI8cPnAPbguiJxYZ+ulizd7Vcsq2z/hytuGv3Lmk165zUiUYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.1",
|
||||
"reduce-css-calc": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/tooltip": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/tooltip/-/tooltip-4.0.0.tgz",
|
||||
"integrity": "sha512-+6J2h5qPvniT0pQtxQrLYGErPRVHr3pRfcMkNqTxD/HgofDGsL6vEBkjMR535MF1hkWYQ9XzOiWP6ERN4TF+mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@visx/bounds": "4.0.0",
|
||||
"classnames": "^2.3.1",
|
||||
"react-use-measure": "^2.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@visx/vendor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0.tgz",
|
||||
"integrity": "sha512-LoBNzWjTXzBfu095BQxB14IwZQHHOLs8ZMzM6t2FL4ZGORaACgLcmXRRLhFo0VmRR8L7iNTM4HHc1j6yFvzsAA==",
|
||||
"license": "MIT and ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "3.0.3",
|
||||
"@types/d3-color": "3.1.0",
|
||||
"@types/d3-delaunay": "6.0.1",
|
||||
"@types/d3-format": "3.0.1",
|
||||
"@types/d3-geo": "3.1.0",
|
||||
"@types/d3-interpolate": "3.0.1",
|
||||
"@types/d3-path": "3.1.1",
|
||||
"@types/d3-scale": "4.0.2",
|
||||
"@types/d3-shape": "3.1.7",
|
||||
"@types/d3-time": "3.0.0",
|
||||
"@types/d3-time-format": "2.1.0",
|
||||
"d3-array": "3.2.1",
|
||||
"d3-color": "3.1.0",
|
||||
"d3-delaunay": "6.0.2",
|
||||
"d3-format": "3.1.0",
|
||||
"d3-geo": "3.1.0",
|
||||
"d3-interpolate": "3.0.1",
|
||||
"d3-path": "3.1.0",
|
||||
"d3-scale": "4.0.2",
|
||||
"d3-shape": "3.2.0",
|
||||
"d3-time": "3.1.0",
|
||||
"d3-time-format": "4.1.0",
|
||||
"internmap": "2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
|
||||
@@ -2211,6 +2506,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
|
||||
"integrity": "sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.12",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
|
||||
@@ -2299,6 +2600,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -2351,9 +2658,136 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
|
||||
"integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
|
||||
"integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -2393,6 +2827,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -2533,6 +2976,15 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
@@ -2946,6 +3398,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-expression-evaluator": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz",
|
||||
"integrity": "sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
@@ -3254,6 +3712,47 @@
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-use-measure": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||
"integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13",
|
||||
"react-dom": ">=16.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/reduce-css-calc": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz",
|
||||
"integrity": "sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^0.4.2",
|
||||
"math-expression-evaluator": "^1.2.14",
|
||||
"reduce-function-call": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/reduce-function-call": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz",
|
||||
"integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reduce-function-call/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -3264,6 +3763,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
"@stylexjs/stylex": "0.19.0",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-router": "1.170.18",
|
||||
"@visx/axis": "4.0.0",
|
||||
"@visx/grid": "4.0.0",
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/shape": "4.0.0",
|
||||
"@visx/tooltip": "4.0.0",
|
||||
"react": "19.2.8",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "19.2.8"
|
||||
|
||||
@@ -14,7 +14,7 @@ import { readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const BUDGET_BYTES = 800_000;
|
||||
const BUDGET_BYTES = 850_000;
|
||||
|
||||
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
@@ -313,16 +313,17 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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".
|
||||
*/
|
||||
/** The related-actions region of a query detail. */
|
||||
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 () => {
|
||||
/**
|
||||
* Related is links only. Pause is a resolver-wide control and lives in the
|
||||
* sidebar alone, so a blocked query — the case that used to carry one here —
|
||||
* offers no button of any kind.
|
||||
*/
|
||||
test("Related carries its four links and no control, blocked query or not", async () => {
|
||||
responses["/api/queries/50"] = detail(50, {
|
||||
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
||||
route: { kind: "blocked", upstream: "" },
|
||||
@@ -330,25 +331,16 @@ test("a blocked query's Related offers Pause; an allowed one has nothing to paus
|
||||
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();
|
||||
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
|
||||
expect(
|
||||
within(related())
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.textContent),
|
||||
).toEqual([
|
||||
"Test this domain against current policy",
|
||||
"All activity for this domain",
|
||||
"All activity from this client",
|
||||
"Diagnostics around this query",
|
||||
]);
|
||||
expect(within(related()).queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -68,15 +68,7 @@ export default function ActivityDetailPage() {
|
||||
<ProvenanceDetail
|
||||
provenance={detail}
|
||||
persistedId={detail.id}
|
||||
relatedActions={
|
||||
<RelatedActions
|
||||
domain={domain}
|
||||
client={client}
|
||||
ts={time}
|
||||
origin={origin}
|
||||
blocked={detail.policy.action === "block"}
|
||||
/>
|
||||
}
|
||||
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* The filter toolbar on its own: the controls that decide what reaches the URL,
|
||||
* driven through a harness that plays the part the page plays.
|
||||
*
|
||||
* The timezone is fixed because the only invalid bound reachable in jsdom is a
|
||||
* wall-clock time inside a spring-forward gap: jsdom applies the
|
||||
* `datetime-local` value sanitization algorithm, so text that is merely
|
||||
* incomplete never reaches the component at all — the input hands it back as
|
||||
* the empty string, which is a bound the operator cleared.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { afterAll, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||
import { unixToDatetimeLocal } from "./datetime";
|
||||
|
||||
vi.stubEnv("TZ", "Europe/Paris");
|
||||
afterAll(() => vi.unstubAllEnvs());
|
||||
|
||||
/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
|
||||
const GAP_WALL_TIME = "2026-03-29T02:30:00";
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's half of the contract: the applied state is held outside the form.
|
||||
*
|
||||
* `deferred` holds the patches back instead of applying them, so a test can
|
||||
* land them in an order the network and the router can genuinely produce —
|
||||
* an early debounce arriving after a later one, over a draft that has moved on.
|
||||
*/
|
||||
function renderFilters(initial: AppliedFilters = NO_FILTERS, deferred = false) {
|
||||
const patches: Array<Partial<AppliedFilters>> = [];
|
||||
const state: { current: AppliedFilters } = { current: initial };
|
||||
const setter: { current: ((next: AppliedFilters) => void) | null } = { current: null };
|
||||
|
||||
function Harness() {
|
||||
const [applied, setApplied] = useState(initial);
|
||||
state.current = applied;
|
||||
setter.current = setApplied;
|
||||
const onApply = useCallback((patch: Partial<AppliedFilters>) => {
|
||||
patches.push(patch);
|
||||
if (!deferred) setApplied((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
const onClear = useCallback(() => {
|
||||
patches.push({});
|
||||
if (!deferred) setApplied(NO_FILTERS);
|
||||
}, []);
|
||||
return <ActivityFilters applied={applied} onApply={onApply} onClear={onClear} />;
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
/** The URL moving under the form: a commit landing, or the back button. */
|
||||
function land(next: AppliedFilters) {
|
||||
act(() => setter.current!(next));
|
||||
}
|
||||
return { patches, state, land };
|
||||
}
|
||||
|
||||
function openTimeMenu() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||
}
|
||||
|
||||
test("the segments commit to the applied state on the click, with no button in between", () => {
|
||||
const { patches, state } = renderFilters();
|
||||
expect(screen.getAllByRole("radio").map((radio) => radio.closest("label")?.textContent)).toEqual([
|
||||
"Any",
|
||||
"Blocked",
|
||||
"Allowed",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
expect(patches).toEqual([{ blocked: true }]);
|
||||
expect(state.current.blocked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Allowed" }));
|
||||
expect(state.current.blocked).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Any" }));
|
||||
expect(state.current.blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a time preset writes an absolute second, and the trigger names the preset", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { patches } = renderFilters();
|
||||
|
||||
openTimeMenu();
|
||||
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
|
||||
"Any time",
|
||||
"Past hour",
|
||||
"Past 24 hours",
|
||||
"Past 7 days",
|
||||
"Custom…",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));
|
||||
|
||||
// Both bounds, concrete: an open upper bound would keep taking in queries
|
||||
// logged after the reader stopped looking, so the same link tomorrow would
|
||||
// name a different day.
|
||||
expect(patches).toEqual([{ since: now / 1000 - 86_400, until: now / 1000 }]);
|
||||
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
|
||||
|
||||
// A clock that has moved on does not move the label: the URL still holds the
|
||||
// second the click resolved to.
|
||||
vi.spyOn(Date, "now").mockReturnValue(now + 60_000);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("bounds nobody picked here read as Custom, and Any time clears both", () => {
|
||||
const { patches } = renderFilters({ ...NO_FILTERS, since: 1_700_000_000, until: 1_700_000_600 });
|
||||
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
|
||||
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Any time" }));
|
||||
|
||||
expect(patches).toEqual([{ since: undefined, until: undefined }]);
|
||||
expect(screen.getByRole("button", { name: "Time: Any time" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the custom range applies only through Set range, and Enter is Set range", () => {
|
||||
const { patches } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
fireEvent.change(since, { target: { value: "2026-03-29T04:30:00" } });
|
||||
// Typing a bound is not applying it: the other half may still be half-typed.
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
const applied = { since: Math.floor(new Date("2026-03-29T04:30:00").getTime() / 1000), until: undefined };
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(patches).toEqual([applied]);
|
||||
|
||||
// Enter inside a bound is that bound's action, not the toolbar's submit: the
|
||||
// outer form flushes the text filters and would apply neither half of this.
|
||||
fireEvent.change(since, { target: { value: "2026-03-29T05:30:00" } });
|
||||
fireEvent.keyDown(since, { key: "Enter" });
|
||||
expect(patches).toHaveLength(2);
|
||||
expect(patches[1]).toEqual({
|
||||
since: Math.floor(new Date("2026-03-29T05:30:00").getTime() / 1000),
|
||||
until: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("a rejected bound marks its own input and describes it", () => {
|
||||
const { patches } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
|
||||
expect(since.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(since)).toContain("daylight saving");
|
||||
// Refused means refused, and only the offending bound carries the mark.
|
||||
expect(patches).toEqual([]);
|
||||
expect(until.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(until.getAttribute("aria-describedby")).toBeNull();
|
||||
});
|
||||
|
||||
test("an upper bound below the lower one is refused, against the bound that is wrong", () => {
|
||||
const { patches } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||
fireEvent.change(since, { target: { value: "2026-05-02T10:00:00" } });
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
|
||||
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(until)).toContain("selects nothing");
|
||||
expect(since.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
// The window is half-open, so two equal bounds are as empty as an inverted
|
||||
// pair and are refused the same way.
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T10:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T11:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(until.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(patches).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Clear keeps its place while there is nothing to clear, and stays out of the tab order", () => {
|
||||
const { state } = renderFilters();
|
||||
const clear = screen.getByText("Clear");
|
||||
expect(clear.getAttribute("tabindex")).toBe("-1");
|
||||
expect(clear.getAttribute("aria-hidden")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
|
||||
expect(clear.getAttribute("tabindex")).toBeNull();
|
||||
expect(clear.getAttribute("aria-hidden")).toBeNull();
|
||||
|
||||
fireEvent.click(clear);
|
||||
expect(state.current).toEqual(NO_FILTERS);
|
||||
expect(clear.getAttribute("tabindex")).toBe("-1");
|
||||
});
|
||||
|
||||
test("Clear empties the text drafts along with the applied filters", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { state } = renderFilters();
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
fireEvent.change(domain, { target: { value: "ads" } });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(state.current.domain).toBe("ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
|
||||
expect(domain.value).toBe("");
|
||||
expect(state.current).toEqual(NO_FILTERS);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("the domain field is search-shaped, unspellchecked, and labelled without a visible label", () => {
|
||||
renderFilters();
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
expect(domain.type).toBe("search");
|
||||
expect(domain.getAttribute("spellcheck")).toBe("false");
|
||||
expect(domain.getAttribute("autocomplete")).toBe("off");
|
||||
expect(domain.getAttribute("placeholder")).toBe("Filter domains…");
|
||||
// The magnifier is decoration over the field, never a second thing to read.
|
||||
expect(document.querySelector("svg[aria-hidden='true']")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a bound change does not reset the domain draft that is still being typed", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { state } = renderFilters();
|
||||
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
fireEvent.change(domain, { target: { value: "ads" } });
|
||||
|
||||
// A preset commits at once, while the typed word is still waiting out its
|
||||
// debounce. The URL moves, but not the part of it this field is derived from.
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
|
||||
expect(state.current.since).toBe(now / 1000 - 3600);
|
||||
expect(domain.value).toBe("ads");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("a debounced commit landing late does not roll the field back over newer typing", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { patches, land } = renderFilters(NO_FILTERS, true);
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
|
||||
// Two commits in flight, and the reader is a keystroke ahead of both.
|
||||
fireEvent.change(domain, { target: { value: "ad" } });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
fireEvent.change(domain, { target: { value: "ads" } });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(patches).toEqual([
|
||||
{ domain: "ad", client: undefined },
|
||||
{ domain: "ads", client: undefined },
|
||||
]);
|
||||
fireEvent.change(domain, { target: { value: "adsx" } });
|
||||
|
||||
// The older one lands first. It is this toolbar's own echo, two keystrokes
|
||||
// stale, and seeding the field from it would delete what was typed since.
|
||||
land({ ...NO_FILTERS, domain: "ad" });
|
||||
expect(domain.value).toBe("adsx");
|
||||
|
||||
land({ ...NO_FILTERS, domain: "ads" });
|
||||
expect(domain.value).toBe("adsx");
|
||||
|
||||
// An address that was never sent from here is someone else's — a pasted
|
||||
// link, or the back button — and that one does move the field.
|
||||
land({ ...NO_FILTERS, domain: "elsewhere" });
|
||||
expect(domain.value).toBe("elsewhere");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a window moved from outside re-opens the custom row over the bounds it arrived with", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { land } = renderFilters();
|
||||
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
// A preset supersedes the custom row, so it is shut and the label is the preset.
|
||||
expect(screen.queryByLabelText("Since")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
|
||||
|
||||
// The back button, or a pasted link: a range this form did not choose.
|
||||
const since = 1_700_000_000;
|
||||
land({ ...NO_FILTERS, since, until: since + 600 });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
|
||||
// The label says Custom, so the fields it names have to be on screen holding
|
||||
// that range — a Custom window with nothing to read is the label lying.
|
||||
// jsdom's value sanitizer spells the milliseconds out, so the seeded text is
|
||||
// the prefix rather than the whole of what the input holds.
|
||||
expect((screen.getByLabelText("Since") as HTMLInputElement).value).toContain(unixToDatetimeLocal(since));
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).value).toContain(unixToDatetimeLocal(since + 600));
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("a window moved from outside clears an error left over from the old one", () => {
|
||||
const { land } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: "2026-05-02T10:00:00" } });
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
const since = 1_700_000_000;
|
||||
land({ ...NO_FILTERS, since, until: since + 600 });
|
||||
|
||||
// The bounds the message was about are gone, so the message is too.
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).getAttribute("aria-invalid")).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("the client field names the exact match it performs", () => {
|
||||
renderFilters();
|
||||
const client = screen.getByLabelText("Client IP (exact match)") as HTMLInputElement;
|
||||
expect(client.getAttribute("placeholder")).toBe("Client IP…");
|
||||
});
|
||||
|
||||
test("an earlier range commit landing late does not unseat the pick that is current", () => {
|
||||
const first = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(first);
|
||||
const { patches, land } = renderFilters(NO_FILTERS, true);
|
||||
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
|
||||
// A second pick before the first has come back through the URL.
|
||||
const second = first + 60_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(second);
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));
|
||||
|
||||
expect(patches).toEqual([
|
||||
{ since: first / 1000 - 3600, until: first / 1000 },
|
||||
{ since: second / 1000 - 86_400, until: second / 1000 },
|
||||
]);
|
||||
|
||||
// The older window lands first. It is this toolbar's own echo, so it must not
|
||||
// read as a range someone else chose: doing that would clear the preset and
|
||||
// throw the custom row open over a window the reader has already moved past.
|
||||
land({ ...NO_FILTERS, since: first / 1000 - 3600, until: first / 1000 });
|
||||
expect(screen.queryByLabelText("Since")).toBeNull();
|
||||
|
||||
land({ ...NO_FILTERS, since: second / 1000 - 86_400, until: second / 1000 });
|
||||
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Since")).toBeNull();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -1,73 +1,198 @@
|
||||
/**
|
||||
* The filter row over the Activity table.
|
||||
* The filter toolbar over the Activity history table.
|
||||
*
|
||||
* The applied state is the URL, never this form: what the reader sees is what
|
||||
* the link they can paste to a housemate will show. So this holds a draft only,
|
||||
* and the page remounts it whenever the applied search changes — a back button
|
||||
* or a pasted URL has to move the form with it, and a form that seeded itself
|
||||
* once would keep showing the previous investigation's filters.
|
||||
* the link they can paste to a housemate will show. So this holds a draft, and
|
||||
* every control writes through to the URL — the segments and the time presets
|
||||
* at once, the two text fields after a pause so a five-letter domain is one
|
||||
* navigation rather than five.
|
||||
*
|
||||
* In live mode the row stays visible and disabled rather than disappearing: the
|
||||
* filters are retained in the URL and apply again the moment history comes
|
||||
* back, and hiding them would read as having lost them. The stream itself is
|
||||
* unfiltered — the server sends every query — so a row that looked usable here
|
||||
* A time preset writes both bounds as absolute seconds, resolved once at the
|
||||
* click. Neither half may be left open: a bookmark has to describe the same
|
||||
* investigation tomorrow, and a window that slid overnight — or one that stayed
|
||||
* open at the top and swallowed everything logged since — would answer a
|
||||
* different question under the same link.
|
||||
*
|
||||
* A URL can move under this form at any time, and the draft only follows the
|
||||
* part of it that actually moved. A field is re-seeded when its own URL value
|
||||
* changed and the new value is not one this toolbar just wrote: the debounce
|
||||
* means the URL is always a little behind the keyboard, and a landing commit
|
||||
* must not roll the input back over the letters typed since.
|
||||
*
|
||||
* The custom range is the one control that does not live-apply. Two half-typed
|
||||
* timestamps are a normal intermediate state of typing one of them, and a lower
|
||||
* bound at or above an upper bound selects nothing at all, so the pair is
|
||||
* validated and applied together or not at all.
|
||||
*
|
||||
* Live mode does not render this at all — the page mounts it inside the History
|
||||
* panel. The stream is unfiltered, and a row of controls that looked usable
|
||||
* would promise filtering that is not happening.
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import Select from "@/ui/Select";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
{ value: "allowed", label: "Allowed only" },
|
||||
];
|
||||
/** Long enough that a typed word is one navigation, short enough to feel live. */
|
||||
const DEBOUNCE_MS = 350;
|
||||
|
||||
const RESULTS = [
|
||||
{ value: "any", label: "Any" },
|
||||
{ value: "blocked", label: "Blocked" },
|
||||
{ value: "allowed", label: "Allowed" },
|
||||
] as const;
|
||||
|
||||
const PRESETS = [
|
||||
{ label: "Any time", seconds: null },
|
||||
{ label: "Past hour", seconds: 3600 },
|
||||
{ label: "Past 24 hours", seconds: 86_400 },
|
||||
{ label: "Past 7 days", seconds: 604_800 },
|
||||
] as const;
|
||||
|
||||
const CUSTOM_ITEM = "Custom…";
|
||||
|
||||
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
|
||||
const HIT_TARGET = 44;
|
||||
|
||||
const styles = stylex.create({
|
||||
/** One column on a phone, two from `sm`, five from `lg`. */
|
||||
grid: {
|
||||
toolbar: {
|
||||
marginTop: "1rem",
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
/** The domain field is the one that grows; everything else keeps its size. */
|
||||
searchWrap: {
|
||||
position: "relative",
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
flexBasis: "14rem",
|
||||
display: "flex",
|
||||
},
|
||||
label: {
|
||||
display: "block",
|
||||
searchIcon: {
|
||||
position: "absolute",
|
||||
insetInlineStart: "0.5rem",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: colors.textMuted,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
/** Every control in the row is a pointer target before it is anything else. */
|
||||
field: {
|
||||
minHeight: HIT_TARGET,
|
||||
},
|
||||
searchInput: {
|
||||
width: "100%",
|
||||
paddingInlineStart: "1.875rem",
|
||||
},
|
||||
clientInput: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexBasis: "10rem",
|
||||
},
|
||||
/** A button is text-sized by default; this is the hit area around the text. */
|
||||
hitTarget: {
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
resultGroup: {
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
/** The segment styling of the Overview period picker, item for item. */
|
||||
segment: {
|
||||
cursor: "pointer",
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.625rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
||||
segmentFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
||||
segmentSelected: {
|
||||
backgroundColor: {
|
||||
default: "oklch(92% 0.004 286.32)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||
},
|
||||
color: colors.text,
|
||||
fontWeight: 500,
|
||||
},
|
||||
segmentIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
popover: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menu: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
menuItem: {
|
||||
cursor: "pointer",
|
||||
paddingInline: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
whiteSpace: "nowrap",
|
||||
minHeight: HIT_TARGET,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
/** Inset because an item flush against the popover edge clips an outset ring. */
|
||||
menuItemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
/**
|
||||
* Clear keeps its box when there is nothing to clear. It appears the moment a
|
||||
* filter is set, and a control that appeared by widening the row would move
|
||||
* every other control out from under the pointer that was reaching for it.
|
||||
*/
|
||||
clearHidden: {
|
||||
visibility: "hidden",
|
||||
},
|
||||
customRow: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
customField: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
input: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
// A disabled native input keeps its value legible but reads as inert,
|
||||
// matching what RAC does to the Select trigger beside it.
|
||||
cursor: { default: null, ":disabled": "not-allowed" },
|
||||
opacity: { default: null, ":disabled": 0.55 },
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.5rem",
|
||||
gridColumn: {
|
||||
default: null,
|
||||
"@media (min-width: 640px)": "span 2 / span 2",
|
||||
"@media (min-width: 1024px)": "span 5 / span 5",
|
||||
},
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
error: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
@@ -86,6 +211,22 @@ export const NO_FILTERS: AppliedFilters = {
|
||||
until: undefined,
|
||||
};
|
||||
|
||||
/** The half-open window the API reads: `ts >= since` and `ts < until`. */
|
||||
interface Bounds {
|
||||
since: number | undefined;
|
||||
until: number | undefined;
|
||||
}
|
||||
|
||||
/** The two text filters as the URL spells them, with absent written as empty. */
|
||||
interface TextPair {
|
||||
domain: string;
|
||||
client: string;
|
||||
}
|
||||
|
||||
function sameBounds(a: Bounds, b: Bounds): boolean {
|
||||
return a.since === b.since && a.until === b.until;
|
||||
}
|
||||
|
||||
function blockedOption(blocked: boolean | undefined): string {
|
||||
if (blocked === undefined) return "any";
|
||||
return blocked ? "blocked" : "allowed";
|
||||
@@ -96,135 +237,395 @@ function optionBlocked(value: string): boolean | undefined {
|
||||
return value === "allowed" ? false : undefined;
|
||||
}
|
||||
|
||||
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
|
||||
return reason === "unparseable"
|
||||
/** A filter value the URL can carry: trimmed, and empty means absent. */
|
||||
function textFilter(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/** The message plus the bound it belongs to, so that input can point at it. */
|
||||
interface BoundError {
|
||||
field: "since" | "until";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** The toolbar is rendered once per page, so the message can hold a fixed id. */
|
||||
const ERROR_ID = "activity-filter-error";
|
||||
|
||||
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
|
||||
const label = field === "since" ? "Since" : "Until";
|
||||
return {
|
||||
field,
|
||||
message:
|
||||
reason === "unparseable"
|
||||
? `${label} is not a complete date and time.`
|
||||
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
|
||||
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The preset a label is claimed for, kept only while the URL still holds it. */
|
||||
interface ChosenPreset extends Bounds {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this toolbar knows about the URL, and what it is still waiting to see
|
||||
* come back from it.
|
||||
*
|
||||
* `url` and `bounds` are the last values looked at, so a change can be told
|
||||
* apart field by field. `pendingText` and `pendingBounds` are every commit made
|
||||
* here that the URL has not echoed yet. Both are queues rather than single
|
||||
* slots: two keystrokes either side of the debounce put two text commits in
|
||||
* flight, and two menu picks in quick succession do the same to the range. In
|
||||
* both cases the older echo landing second must not be mistaken for someone
|
||||
* else's edit — that is what would clear the preset out from under the pick
|
||||
* that is actually current.
|
||||
*/
|
||||
interface Sync {
|
||||
url: TextPair;
|
||||
bounds: Bounds;
|
||||
pendingText: TextPair[];
|
||||
pendingBounds: Bounds[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
applied: AppliedFilters;
|
||||
isDisabled: boolean;
|
||||
onApply: (filters: AppliedFilters) => void;
|
||||
/** Merges a patch into the applied search. `replace` keeps typing out of history. */
|
||||
onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
|
||||
const [domain, setDomain] = useState(applied.domain ?? "");
|
||||
const [client, setClient] = useState(applied.client ?? "");
|
||||
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
||||
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
||||
const urlText: TextPair = { domain: applied.domain ?? "", client: applied.client ?? "" };
|
||||
const urlBounds: Bounds = { since: applied.since, until: applied.until };
|
||||
|
||||
const [domain, setDomain] = useState(urlText.domain);
|
||||
const [client, setClient] = useState(urlText.client);
|
||||
const [preset, setPreset] = useState<ChosenPreset | null>(null);
|
||||
const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
|
||||
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
|
||||
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<BoundError | null>(null);
|
||||
const [sync, setSync] = useState<Sync>({
|
||||
url: urlText,
|
||||
bounds: urlBounds,
|
||||
pendingText: [],
|
||||
pendingBounds: [],
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
const textMoved = sync.url.domain !== urlText.domain || sync.url.client !== urlText.client;
|
||||
const boundsMoved = !sameBounds(sync.bounds, urlBounds);
|
||||
if (textMoved || boundsMoved) {
|
||||
let pendingText = sync.pendingText;
|
||||
let pendingBounds = sync.pendingBounds;
|
||||
|
||||
if (textMoved) {
|
||||
// The newest commit the URL matches, and everything before it, has now
|
||||
// been accounted for; an older one landing later is not new news.
|
||||
const echo = pendingText.findLastIndex(
|
||||
(sent) => sent.domain === urlText.domain && sent.client === urlText.client,
|
||||
);
|
||||
if (echo >= 0) {
|
||||
pendingText = pendingText.slice(echo + 1);
|
||||
} else {
|
||||
pendingText = [];
|
||||
if (sync.url.domain !== urlText.domain) setDomain(urlText.domain);
|
||||
if (sync.url.client !== urlText.client) setClient(urlText.client);
|
||||
}
|
||||
}
|
||||
|
||||
if (boundsMoved) {
|
||||
const echo = pendingBounds.findLastIndex((sent) => sameBounds(sent, urlBounds));
|
||||
if (echo >= 0) {
|
||||
pendingBounds = pendingBounds.slice(echo + 1);
|
||||
} else {
|
||||
// Someone else moved the window — the back button, or a pasted link.
|
||||
// Whatever this form was showing about the old one is now wrong: the
|
||||
// preset it was named after, an error against bounds that are gone,
|
||||
// and a custom row that is open or shut for the wrong range.
|
||||
pendingBounds = [];
|
||||
setPreset(null);
|
||||
setError(null);
|
||||
setShowCustom(urlBounds.since !== undefined || urlBounds.until !== undefined);
|
||||
setSince(datetimeField(urlBounds.since));
|
||||
setUntil(datetimeField(urlBounds.until));
|
||||
}
|
||||
}
|
||||
|
||||
setSync({ url: urlText, bounds: urlBounds, pendingText, pendingBounds });
|
||||
}
|
||||
|
||||
const dirty = textFilter(domain) !== applied.domain || textFilter(client) !== applied.client;
|
||||
|
||||
const commitText = useCallback(
|
||||
(replace: boolean) => {
|
||||
const next = { domain: textFilter(domain), client: textFilter(client) };
|
||||
setSync((prev) => ({
|
||||
...prev,
|
||||
pendingText: [...prev.pendingText, { domain: next.domain ?? "", client: next.client ?? "" }],
|
||||
}));
|
||||
onApply(next, replace);
|
||||
},
|
||||
[domain, client, onApply],
|
||||
);
|
||||
|
||||
function commitBounds(bounds: Bounds) {
|
||||
setSync((prev) => ({ ...prev, pendingBounds: [...prev.pendingBounds, bounds] }));
|
||||
onApply(bounds);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const id = setTimeout(() => commitText(true), DEBOUNCE_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [dirty, commitText]);
|
||||
|
||||
function flush(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (dirty) commitText(true);
|
||||
}
|
||||
|
||||
function selectPreset(label: string) {
|
||||
if (label === CUSTOM_ITEM) {
|
||||
setShowCustom(true);
|
||||
return;
|
||||
}
|
||||
const option = PRESETS.find((candidate) => candidate.label === label);
|
||||
if (option === undefined) return;
|
||||
setShowCustom(false);
|
||||
setError(null);
|
||||
if (option.seconds === null) {
|
||||
setPreset(null);
|
||||
setSince(datetimeField(undefined));
|
||||
setUntil(datetimeField(undefined));
|
||||
commitBounds({ since: undefined, until: undefined });
|
||||
return;
|
||||
}
|
||||
// Both bounds, resolved once, here. An open upper bound would keep taking
|
||||
// in queries logged after the reader stopped looking, so "the past hour"
|
||||
// would name a different hour every time the link was opened.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const bounds: Bounds = { since: now - option.seconds, until: now };
|
||||
setPreset({ label, ...bounds });
|
||||
setSince(datetimeField(bounds.since));
|
||||
setUntil(datetimeField(bounds.until));
|
||||
commitBounds(bounds);
|
||||
}
|
||||
|
||||
function setRange() {
|
||||
const sinceValue = resolveDatetimeField(since);
|
||||
if (!sinceValue.ok) {
|
||||
setError(boundError("Since", sinceValue.reason));
|
||||
setError(boundError("since", sinceValue.reason));
|
||||
return;
|
||||
}
|
||||
const untilValue = resolveDatetimeField(until);
|
||||
if (!untilValue.ok) {
|
||||
setError(boundError("Until", untilValue.reason));
|
||||
setError(boundError("until", untilValue.reason));
|
||||
return;
|
||||
}
|
||||
// The window is half-open — `ts >= since` and `ts < until` — so two equal
|
||||
// bounds are as empty as an inverted pair, and neither is worth applying.
|
||||
if (sinceValue.value !== undefined && untilValue.value !== undefined && untilValue.value <= sinceValue.value) {
|
||||
setError({
|
||||
field: "until",
|
||||
message: "Until must be after Since, or the range selects nothing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onApply({
|
||||
domain: domain.trim() === "" ? undefined : domain.trim(),
|
||||
client: client.trim() === "" ? undefined : client.trim(),
|
||||
blocked: optionBlocked(blocked),
|
||||
since: sinceValue.value,
|
||||
until: untilValue.value,
|
||||
});
|
||||
setPreset(null);
|
||||
commitBounds({ since: sinceValue.value, until: untilValue.value });
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setDomain("");
|
||||
setClient("");
|
||||
setBlocked("any");
|
||||
setPreset(null);
|
||||
setShowCustom(false);
|
||||
setSince(datetimeField(undefined));
|
||||
setUntil(datetimeField(undefined));
|
||||
setError(null);
|
||||
setSync((prev) => ({
|
||||
...prev,
|
||||
pendingText: [],
|
||||
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
|
||||
}));
|
||||
onClear();
|
||||
}
|
||||
|
||||
const active =
|
||||
domain.trim() !== "" ||
|
||||
client.trim() !== "" ||
|
||||
applied.blocked !== undefined ||
|
||||
applied.since !== undefined ||
|
||||
applied.until !== undefined;
|
||||
|
||||
// The label the URL earns on its own, overridden only while a preset click is
|
||||
// still the whole of what the URL says. Bounds nobody here chose read as
|
||||
// "Custom": that is what a pasted link or an edited range is.
|
||||
let timeLabel = "Any time";
|
||||
if (applied.since !== undefined || applied.until !== undefined) {
|
||||
timeLabel = preset !== null && sameBounds(preset, urlBounds) ? preset.label : "Custom";
|
||||
}
|
||||
|
||||
/** True for the one bound the current message is about; nothing else is marked. */
|
||||
const invalid = (field: BoundError["field"]): true | undefined =>
|
||||
error !== null && error.field === field ? true : undefined;
|
||||
|
||||
function boundInput(field: BoundError["field"]) {
|
||||
const state = field === "since" ? since : until;
|
||||
const set = field === "since" ? setSince : setUntil;
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
<label {...stylex.props(styles.customField)}>
|
||||
{field === "since" ? "Since" : "Until"}
|
||||
<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)}
|
||||
value={state.text}
|
||||
aria-invalid={invalid(field)}
|
||||
aria-describedby={invalid(field) && ERROR_ID}
|
||||
onChange={(event) => set(editDatetimeField(state, event.target.value))}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
// Enter here means this range, not the toolbar's text filters:
|
||||
// the two bounds only ever apply together, and the outer form's
|
||||
// submit would apply neither of them.
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
setRange();
|
||||
}}
|
||||
onBlur={() => {
|
||||
const resolved = resolveDatetimeField(state);
|
||||
if (!resolved.ok) setError(boundError(field, resolved.reason));
|
||||
else if (invalid(field)) setError(null);
|
||||
}}
|
||||
{...stylex.props(shared.smallInput, styles.field, shared.focusRing)}
|
||||
/>
|
||||
{invalid(field) && (
|
||||
<span id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
|
||||
{error?.message}
|
||||
</span>
|
||||
)}
|
||||
</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)}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={flush}>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<div {...stylex.props(styles.searchWrap)}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
{...stylex.props(styles.searchIcon)}
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
<circle cx="7" cy="7" r="4.5" />
|
||||
<path d="M10.5 10.5 14 14" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Filter domains"
|
||||
placeholder="Filter domains…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
* The client filter matches one address exactly — the server has no
|
||||
* substring match for it — so the name says so rather than leaving the
|
||||
* reader to discover it by typing half an address and getting nothing.
|
||||
*/}
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Client IP (exact match)"
|
||||
placeholder="Client IP…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={client}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.field, styles.clientInput, shared.focusRing)}
|
||||
/>
|
||||
<RadioGroup
|
||||
aria-label="Result"
|
||||
orientation="horizontal"
|
||||
value={blockedOption(applied.blocked)}
|
||||
onChange={(next) => onApply({ blocked: optionBlocked(next) })}
|
||||
className={() => stylex.props(styles.resultGroup).className ?? ""}
|
||||
>
|
||||
{RESULTS.map((option) => (
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.segment,
|
||||
isSelected ? styles.segmentSelected : styles.segmentIdle,
|
||||
isFocusVisible && styles.segmentFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
className={() =>
|
||||
stylex.props(shared.button, styles.hitTarget, shared.focusRing).className ?? ""
|
||||
}
|
||||
>
|
||||
Time: {timeLabel}
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
<Menu {...stylex.props(styles.menu)}>
|
||||
{[...PRESETS.map((option) => option.label), CUSTOM_ITEM].map((label) => (
|
||||
<MenuItem
|
||||
key={label}
|
||||
onAction={() => selectPreset(label)}
|
||||
className={({ isFocused }) =>
|
||||
stylex.props(
|
||||
styles.menuItem,
|
||||
shared.insetFocusRing,
|
||||
isFocused && styles.menuItemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
disabled={isDisabled}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
tabIndex={active ? undefined : -1}
|
||||
aria-hidden={active ? undefined : true}
|
||||
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing, !active && styles.clearHidden)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error !== null && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{error}
|
||||
</p>
|
||||
{showCustom && (
|
||||
<div {...stylex.props(styles.customRow)}>
|
||||
{boundInput("since")}
|
||||
{boundInput("until")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={setRange}
|
||||
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
|
||||
>
|
||||
Set range
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
@@ -135,6 +136,22 @@ function queryCalls(): string[] {
|
||||
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
||||
}
|
||||
|
||||
/** The toolbar's search field, which is how a domain filter is entered now. */
|
||||
function domainInput(): HTMLInputElement {
|
||||
return screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
}
|
||||
|
||||
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
|
||||
function submitFilters() {
|
||||
fireEvent.submit(domainInput().closest("form")!);
|
||||
}
|
||||
|
||||
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
||||
function openCustomRange() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
}
|
||||
|
||||
test("renders the first page with the seven columns filled in", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
@@ -162,7 +179,7 @@ test("renders the first page with the seven columns filled in", async () => {
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
@@ -182,19 +199,22 @@ test("resolves each row's client to its display name, keeping the IP as the tool
|
||||
|
||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
// The address reads out with the name it replaced, rather than sitting in a
|
||||
// title only a mouse can reach.
|
||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||
expect(named.getAttribute("title")).toBeNull();
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with no tooltip standing in.
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
// seen, both fall back to the bare address with nothing standing in for it.
|
||||
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
|
||||
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
|
||||
});
|
||||
|
||||
test("load more appends the next page and stops at the end of the log", async () => {
|
||||
@@ -216,8 +236,8 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
@@ -242,8 +262,8 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
@@ -281,8 +301,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
|
||||
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
||||
expect(staleButton).toHaveProperty("disabled", true);
|
||||
@@ -416,7 +436,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
|
||||
renderPage("/activity?domain=ads");
|
||||
|
||||
await screen.findByText("ads.example");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
||||
expect(domainInput()).toHaveProperty("value", "ads");
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -441,7 +461,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
|
||||
await screen.findByText("first.example");
|
||||
// Nothing survived validation, so the request is the unfiltered one.
|
||||
expect(queryCalls()).toEqual(["/api/queries"]);
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the form draft follows the url back and forward, seconds included", async () => {
|
||||
@@ -454,25 +474,33 @@ test("the form draft follows the url back and forward, seconds included", async
|
||||
const seeded = 1_700_000_017;
|
||||
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
||||
|
||||
const domainInput = await screen.findByLabelText("Domain contains");
|
||||
expect(domainInput).toHaveProperty("value", "first");
|
||||
await screen.findByLabelText("Filter domains");
|
||||
expect(domainInput()).toHaveProperty("value", "first");
|
||||
// A seeded custom range opens its row, so the link's bounds are visible.
|
||||
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
expect(sinceInput.value).toContain(":37");
|
||||
|
||||
fireEvent.change(domainInput, { target: { value: "second" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "second" } });
|
||||
submitFilters();
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
||||
// The untouched Since bound applied as the exact second it was seeded with.
|
||||
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
|
||||
|
||||
// A pasted link, then the buttons over it: the draft is derived from the URL,
|
||||
// so whichever way the browser moves it the field has to move with it.
|
||||
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
|
||||
await waitFor(() => {
|
||||
expect(domainInput()).toHaveProperty("value", "third");
|
||||
});
|
||||
|
||||
act(() => history.back());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
|
||||
expect(domainInput()).toHaveProperty("value", "second");
|
||||
});
|
||||
|
||||
act(() => history.forward());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
|
||||
expect(domainInput()).toHaveProperty("value", "third");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -508,8 +536,9 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
|
||||
const callsBefore = queryCalls().length;
|
||||
const searchBefore = history.location.search;
|
||||
|
||||
openCustomRange();
|
||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
|
||||
if (inGap) {
|
||||
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
||||
@@ -544,6 +573,40 @@ test("the policy simulation is reachable from the header, with no rows to click
|
||||
expect(history.location.pathname).toBe("/activity/test");
|
||||
});
|
||||
|
||||
test("the mode switch is a tab list whose selection is the url, and it keeps the filters", async () => {
|
||||
// Live opens a stream as soon as its panel mounts, so the switch cannot be
|
||||
// exercised without one; the fake stands in for the browser's EventSource.
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
constructor(url: string) {
|
||||
return new FakeEventSource(url) as unknown as EventSource;
|
||||
}
|
||||
},
|
||||
);
|
||||
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||
await screen.findByText("ads.example");
|
||||
|
||||
const tabs = within(screen.getByRole("tablist", { name: "Activity mode" }));
|
||||
expect(tabs.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["History", "Live"]);
|
||||
expect(tabs.getByRole("tab", { name: "History", selected: true })).toBeTruthy();
|
||||
expect(tabs.getByRole("tab", { name: "Live", selected: false })).toBeTruthy();
|
||||
|
||||
fireEvent.click(tabs.getByRole("tab", { name: "Live" }));
|
||||
|
||||
await waitFor(() => expect(history.location.search).toContain("mode=live"));
|
||||
// The investigation survives the switch: both filters are still in the URL.
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
expect(history.location.search).toContain("blocked=true");
|
||||
expect(
|
||||
within(screen.getByRole("tablist", { name: "Activity mode" })).getByRole("tab", {
|
||||
name: "Live",
|
||||
selected: true,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Clear empties the url as well as the form", async () => {
|
||||
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||
await screen.findByText("ads.example");
|
||||
@@ -553,5 +616,130 @@ test("Clear empties the url as well as the form", async () => {
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
});
|
||||
expect(history.location.search).not.toContain("blocked");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(domainInput(), { target: { value: "a" } });
|
||||
fireEvent.change(domainInput(), { target: { value: "ad" } });
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
// Mid-word the URL has not moved: three keystrokes are one investigation,
|
||||
// not three, and each one would otherwise be a request and a history entry.
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
// Replaced, not pushed: Back leaves the page, it does not retype the word.
|
||||
expect(history.length).toBe(1);
|
||||
|
||||
fireEvent.change(domainInput(), { target: { value: "first" } });
|
||||
submitFilters();
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=first"));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("the field being typed in keeps the focus when the debounce commits", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
const input = domainInput();
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value: "ads" } });
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=ads"));
|
||||
|
||||
// The same node, still focused, still holding the caret: a toolbar that
|
||||
// remounted on the URL it just wrote would drop the next keystroke.
|
||||
expect(domainInput()).toBe(input);
|
||||
expect(document.activeElement).toBe(input);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a result segment commits on the click, with no wait and no button", async () => {
|
||||
const { history } = renderPage("/activity?domain=ads");
|
||||
await screen.findByText("ads.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
|
||||
await waitFor(() => expect(history.location.search).toContain("blocked=true"));
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
});
|
||||
|
||||
test("a time preset writes the second it resolved to, not a rolling window", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("No queries logged yet.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Time: Any time" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
|
||||
// Both bounds are concrete, so the link names a closed hour rather than one
|
||||
// that keeps growing at the top as the log does.
|
||||
const since = now / 1000 - 3600;
|
||||
const until = now / 1000;
|
||||
await waitFor(() => expect(history.location.search).toContain(`since=${since}`));
|
||||
expect(history.location.search).toContain(`until=${until}`);
|
||||
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
|
||||
expect(queryCalls()).toContain(`/api/queries?since=${since}&until=${until}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("Live shows no toolbar at all", async () => {
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
constructor(url: string) {
|
||||
return new FakeEventSource(url) as unknown as EventSource;
|
||||
}
|
||||
},
|
||||
);
|
||||
renderPage("/activity?mode=live&domain=ads");
|
||||
await screen.findByText(/the History filters apply to history only/);
|
||||
|
||||
// The stream is unfiltered, so a control here would promise filtering that is
|
||||
// not happening; the filters are still in the URL, waiting for History.
|
||||
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
||||
});
|
||||
|
||||
test("the coverage watermark reads under the results, never over them", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [row(20, "kept.example")],
|
||||
next_before: null,
|
||||
coverage: { complete: false, available_since: 1_700_000_000 },
|
||||
} satisfies QueriesPage);
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText("kept.example");
|
||||
|
||||
const watermark = screen.getByText(/Query history is available from/);
|
||||
const count = screen.getByText(/Showing 1 query/);
|
||||
// After the count in document order, which is what "footer" means here.
|
||||
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* recipient should be looking at.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||
@@ -53,6 +55,13 @@ const styles = stylex.create({
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
},
|
||||
/** A Tab is a `div` with a roving tabindex, so RAC drives the ring, not `:focus-visible`. */
|
||||
modeFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
modeIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||
@@ -76,13 +85,33 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
panel: {
|
||||
outlineStyle: "none",
|
||||
},
|
||||
/**
|
||||
* A panel with nothing tabbable in it — an empty or loading history — is given
|
||||
* a tabindex by RAC so the reader can still reach its content, so it has to be
|
||||
* able to show that it holds the focus.
|
||||
*/
|
||||
panelFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
|
||||
tabsRoot: {
|
||||
display: "block",
|
||||
},
|
||||
});
|
||||
|
||||
function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
|
||||
return stylex.props(styles.panel, isFocusVisible && styles.panelFocusVisible).className ?? "";
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const search = useSearch({ from: "/shell/activity" });
|
||||
const navigate = useNavigate({ from: "/activity" });
|
||||
const live = search.mode === "live";
|
||||
|
||||
// The functional form, not a replacement object: the filters are retained
|
||||
// across a mode switch on purpose, and spelling out a new search here would
|
||||
// drop every one of them on the way to Live and back.
|
||||
@@ -91,62 +120,72 @@ export default function ActivityPage() {
|
||||
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
||||
}
|
||||
|
||||
function apply(filters: AppliedFilters) {
|
||||
void navigate({ search: { mode: search.mode, ...filters } });
|
||||
}
|
||||
// A patch, merged into whatever the URL already says: a segment click must not
|
||||
// spell out the four filters it is not about. `replace` is the debounced text
|
||||
// commits, so the back button steps between investigations, not keystrokes.
|
||||
const apply = useCallback(
|
||||
(patch: Partial<AppliedFilters>, replace = false) => {
|
||||
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/*
|
||||
* The selected tab is the URL's `mode` and nothing else. RAC would hold
|
||||
* the selection itself, but a second copy of it would fight the back
|
||||
* button, so the search parameter stays the only state there is.
|
||||
*/}
|
||||
<Tabs
|
||||
selectedKey={search.mode}
|
||||
onSelectionChange={(key) => selectMode(key as ActivityMode)}
|
||||
className={() => stylex.props(styles.tabsRoot).className ?? ""}
|
||||
>
|
||||
<div {...stylex.props(styles.header)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
||||
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
|
||||
{MODES.map((option) => {
|
||||
const selected = option.mode === search.mode;
|
||||
return (
|
||||
<button
|
||||
<TabList aria-label="Activity mode" className={() => stylex.props(styles.switch).className ?? ""}>
|
||||
{MODES.map((option) => (
|
||||
<Tab
|
||||
key={option.mode}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => selectMode(option.mode)}
|
||||
{...stylex.props(
|
||||
id={option.mode}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.modeButton,
|
||||
selected ? styles.modeSelected : styles.modeIdle,
|
||||
shared.focusRing,
|
||||
)}
|
||||
isSelected ? styles.modeSelected : styles.modeIdle,
|
||||
isFocusVisible && styles.modeFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<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.
|
||||
* The toolbar belongs to History alone. It is not remounted on a search
|
||||
* change: it resyncs its draft from the URL instead, because a remount
|
||||
* mid-debounce would take the focus out of the input being typed in.
|
||||
*/}
|
||||
<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 ? (
|
||||
<>
|
||||
<TabPanel id="history" className={panelClass}>
|
||||
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
||||
<HistoryActivity search={search} />
|
||||
</TabPanel>
|
||||
<TabPanel id="live" className={panelClass}>
|
||||
<p {...stylex.props(styles.liveNote)}>
|
||||
The stream carries every query the server answers; these filters apply to history only.
|
||||
The stream carries every query the server answers; the History filters apply to history only.
|
||||
</p>
|
||||
<LiveActivity origin={search} />
|
||||
</>
|
||||
) : (
|
||||
<HistoryActivity search={search} />
|
||||
)}
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
refetching: {
|
||||
/** The gap a line standing on its own needs from the block above it. */
|
||||
spacedTop: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
moreButton: {
|
||||
@@ -81,7 +82,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const coverage = pages[0]?.coverage;
|
||||
// Only from the settled response. Placeholder pages belong to the previous
|
||||
// filter, and a watermark is a claim about the window being displayed.
|
||||
const coverage = base.isPlaceholderData ? undefined : pages[0]?.coverage;
|
||||
const filterActive = Object.keys(filter).length > 0;
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
@@ -112,16 +115,27 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
* A quiet line, never a skeleton: the rows on screen stay put while a
|
||||
* filter change is in flight, so a keystroke must not blank the table
|
||||
* it is narrowing.
|
||||
*/}
|
||||
{base.isFetching && (
|
||||
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
||||
Loading…
|
||||
<p {...stylex.props(styles.note, styles.spacedTop)} role="status">
|
||||
Updating…
|
||||
</p>
|
||||
)}
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||
{rows.length === 0 ? (
|
||||
<>
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
{coverage !== undefined && (
|
||||
<div {...stylex.props(styles.spacedTop)}>
|
||||
<CoverageNotice coverage={coverage} variant="note" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
@@ -158,6 +172,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -57,7 +57,17 @@ function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
|
||||
/**
|
||||
* The empty history page. Live mode asks for no query pages, but switching back
|
||||
* to History does, and a page-shaped response is the only honest answer there.
|
||||
*/
|
||||
const EMPTY_PAGE = { queries: [], next_before: null, coverage: { complete: true, available_since: 0 } };
|
||||
|
||||
function defaultHandler(url: string): Response {
|
||||
return json(url === "/api/queries" || url.startsWith("/api/queries?") ? EMPTY_PAGE : {});
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response> = defaultHandler) {
|
||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||
@@ -168,7 +178,7 @@ test("streams rows, flags blocked ones, and freezes the display", async () => {
|
||||
expect(screen.getByText("later.example")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||
await openLive();
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
|
||||
@@ -178,11 +188,14 @@ test("resolves each row's client to its display name, keeping the IP as the tool
|
||||
});
|
||||
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
// The address reads out with the name it replaced, rather than sitting in a
|
||||
// title only a mouse can reach.
|
||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||
expect(named.getAttribute("title")).toBeNull();
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
@@ -256,7 +269,40 @@ test("a recovered row links to its stored detail; a streamed one opens in place
|
||||
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a streamed row opens its own provenance, from the keyboard as well as the pointer", async () => {
|
||||
/** The open detail. Named by its heading, so the query proves the name is visible. */
|
||||
function detailDialog(): HTMLElement {
|
||||
return screen.getByRole("dialog", { name: "Streamed query" });
|
||||
}
|
||||
|
||||
/** React Aria's ModalOverlay, two levels out from the dialog it wraps. */
|
||||
function backdrop(): HTMLElement {
|
||||
return detailDialog().parentElement!.parentElement!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a control the way a keyboard does. jsdom runs no default action for
|
||||
* Enter on a button, so the click a browser would then dispatch is issued here;
|
||||
* `detail: 0` is what marks it as keyboard-driven rather than pointer-driven,
|
||||
* and is the flag React Aria itself reads.
|
||||
*/
|
||||
function pressWithKeyboard(control: HTMLElement) {
|
||||
act(() => control.focus());
|
||||
fireEvent.keyDown(control, { key: "Enter" });
|
||||
fireEvent.click(control, { detail: 0 });
|
||||
fireEvent.keyUp(control, { key: "Enter" });
|
||||
}
|
||||
|
||||
/** Activate a control the way a mouse does, through the full pointer sequence. */
|
||||
function pressWithMouse(control: HTMLElement) {
|
||||
fireEvent.pointerDown(control, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.pointerUp(control, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.click(control, { detail: 1 });
|
||||
}
|
||||
|
||||
test.each([
|
||||
["the pointer", pressWithMouse],
|
||||
["the keyboard", pressWithKeyboard],
|
||||
])("a streamed row opens its provenance in a named dialog, from %s", async (_label, press) => {
|
||||
await openLive();
|
||||
act(() =>
|
||||
sources[0]!.emit(
|
||||
@@ -273,69 +319,108 @@ test("a streamed row opens its own provenance, from the keyboard as well as the
|
||||
// only way to lose that is to opt out of it, which nothing here may do.
|
||||
expect(trigger.tagName).toBe("BUTTON");
|
||||
expect(trigger.getAttribute("tabindex")).toBeNull();
|
||||
act(() => trigger.focus());
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
// The row opens a dialog, so it says so; what it no longer claims is to
|
||||
// expand a region that stays in the page.
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBeNull();
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
const heading = screen.getByRole("heading", { level: 1, name: "streamed.example" });
|
||||
expect(heading).toBeTruthy();
|
||||
const panel = heading.closest("div")!.parentElement!;
|
||||
expect(within(panel).getByText("Blocked locally")).toBeTruthy();
|
||||
expect(panel.textContent).toContain("the query log may not have written it yet");
|
||||
press(trigger);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
|
||||
const dialog = detailDialog();
|
||||
expect(within(dialog).getByRole("heading", { level: 1, name: "streamed.example" })).toBeTruthy();
|
||||
expect(within(dialog).getByText("Blocked locally")).toBeTruthy();
|
||||
expect(dialog.textContent).toContain("the query log may not have written it yet");
|
||||
// React Aria may defer the move by a frame, depending on the modality it read
|
||||
// from the activation, so the wait is the assertion rather than a workaround.
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
expect(within(dialog).getByRole("button", { name: "Close" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
|
||||
test("tabbing forward and backward stays inside the open dialog", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
const dialog = detailDialog();
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
// The related links land asynchronously; tabbing before them would walk a
|
||||
// shorter dialog than the reader ever sees.
|
||||
await waitFor(() => expect(within(dialog).getAllByRole("link").length).toBeGreaterThan(1));
|
||||
|
||||
const visited = new Set<Element>();
|
||||
for (const shiftKey of [false, false, false, false, false, false, true, true, true, true]) {
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Tab", shiftKey });
|
||||
fireEvent.keyUp(document.activeElement!, { key: "Tab", shiftKey });
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
visited.add(document.activeElement!);
|
||||
}
|
||||
// Containment that never moved focus would satisfy the check above without
|
||||
// trapping anything, so the walk has to have actually walked.
|
||||
expect(visited.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["the Close button", () => fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }))],
|
||||
["Escape", () => fireEvent.keyDown(detailDialog(), { key: "Escape" })],
|
||||
[
|
||||
"a click on the backdrop",
|
||||
() => {
|
||||
const overlay = backdrop();
|
||||
fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.pointerUp(overlay, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.click(overlay, { detail: 1 });
|
||||
},
|
||||
],
|
||||
])("%s closes the dialog and returns focus to the row that opened it", async (_label, dismiss) => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
// A native button activates on Enter and Space; jsdom does not synthesize
|
||||
// the click those keys fire, so the click is the activation.
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
expect(detailDialog()).toBeTruthy();
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
|
||||
// The panel is inserted above the table, behind the trigger in tab order, so
|
||||
// the only thing that keeps a forward tab inside it is focus moving in.
|
||||
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
|
||||
dismiss();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
|
||||
});
|
||||
|
||||
test("opening a second row moves the expanded state and the focus with it", async () => {
|
||||
test("the stream runs on behind the open dialog, and its rows land in the table on close", async () => {
|
||||
await openLive();
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "first.example"));
|
||||
sources[0]!.emit("query", frame(1001, "second.example"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
const snapshot = detailDialog().textContent;
|
||||
act(() => sources[0]!.emit("query", frame(1001, "arrived-while-open.example")));
|
||||
|
||||
// The connection is untouched: no close, no second EventSource.
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(false);
|
||||
// And the snapshot is a snapshot: nothing that arrives rewrites it.
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
|
||||
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "arrived-while-open.example" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
const first = screen.getByRole("button", { name: "first.example" });
|
||||
const second = screen.getByRole("button", { name: "second.example" });
|
||||
fireEvent.click(first);
|
||||
fireEvent.click(second);
|
||||
test("the rows and toolbar behind the dialog are out of reach while it is open", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(first.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(second.getAttribute("aria-expanded")).toBe("true");
|
||||
// React Aria hides everything outside the modal from assistive technology
|
||||
// and from the pointer alike, so the row and the toolbar are unreachable by
|
||||
// role: nothing behind the dialog can be operated while it is open.
|
||||
expect(screen.queryByRole("button", { name: "streamed.example" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Freeze" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(document.activeElement).toBe(second);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Freeze" })).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -359,23 +444,57 @@ function renderLiveWithCapacity(capacity: number) {
|
||||
);
|
||||
}
|
||||
|
||||
test("an open streamed detail survives the row being evicted from the ring buffer", () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// One ringful more: the ring keeps the newest 5, so the selected row is gone
|
||||
// from the table. The detail is a snapshot, not a lookup into the ring.
|
||||
/** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
|
||||
function evictWithFiller() {
|
||||
act(() => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("an open dialog's snapshot survives its row being evicted from the ring buffer", async () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
const snapshot = detailDialog().textContent;
|
||||
expect(within(detailDialog()).getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// One ringful more: the ring keeps the newest 5, so the selected row is gone.
|
||||
// The dialog holds the frame itself, not a lookup into the ring, so it neither
|
||||
// blanks out nor closes.
|
||||
evictWithFiller();
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
|
||||
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.getAllByRole("row")).toHaveLength(6);
|
||||
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("closing after the source row is evicted anchors focus in the results region", async () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
const trigger = screen.getByRole("button", { name: "evicted.example" });
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
|
||||
evictWithFiller();
|
||||
expect(trigger.isConnected).toBe(false);
|
||||
|
||||
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
|
||||
// Never the body, never whichever row happens to sit where the old one did,
|
||||
// and never the Freeze button: a stable anchor in the region the reader was
|
||||
// reading. React Aria's own deferred restore runs after this and, finding
|
||||
// focus already placed, leaves it alone.
|
||||
const region = screen.getByRole("region", { name: "Live queries" });
|
||||
expect(document.activeElement).toBe(region);
|
||||
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
|
||||
expect(document.activeElement).toBe(region);
|
||||
});
|
||||
|
||||
test("the route renders the live ring at its production capacity", async () => {
|
||||
@@ -391,34 +510,29 @@ test("an open streamed detail survives Freeze and Resume", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
||||
const snapshot = detailDialog().textContent;
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
// Freeze is behind the dialog, so it is reached the way the code reaches it
|
||||
// rather than by role, which the modal deliberately hides.
|
||||
const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
|
||||
act(() => freeze().click());
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
act(() => (screen.getByText("Resume") as HTMLButtonElement).click());
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
});
|
||||
|
||||
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
|
||||
test("live renders no filter toolbar, not even a disabled one", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
||||
|
||||
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
|
||||
expect(domain.value).toBe("ads");
|
||||
expect(domain.disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
|
||||
|
||||
const form = domain.closest("form")!;
|
||||
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
for (const control of controls) {
|
||||
// A disabled form control is skipped by the browser's tab order, and RAC
|
||||
// pins its own trigger out of it as well. Nothing in the row may
|
||||
// reintroduce itself with a reachable tabindex.
|
||||
expect(control.hasAttribute("disabled")).toBe(true);
|
||||
const tabindex = control.getAttribute("tabindex");
|
||||
expect(tabindex === null || tabindex === "-1").toBe(true);
|
||||
}
|
||||
// The stream is unfiltered — the server sends every query — so a row of
|
||||
// controls here would promise filtering that is not happening. The filters
|
||||
// are not lost: they are in the URL, and History applies them on the way back.
|
||||
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
||||
expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
||||
expect(screen.queryByText("Clear")).toBeNull();
|
||||
expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
||||
@@ -431,31 +545,30 @@ test("live mode asks for no query pages, whatever filters the url retained", asy
|
||||
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "History" }));
|
||||
await screen.findByRole("button", { name: "Apply filters" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
await screen.findByLabelText("Filter domains");
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
// The filters came along, which is the point of switching rather than
|
||||
// navigating: the reader keeps the question they were asking.
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
||||
expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Live" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(sources[1]!.closed).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The related-actions region of a query detail. 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".
|
||||
*/
|
||||
/** The related-actions region of a query detail. */
|
||||
function related(): HTMLElement {
|
||||
return screen.getByRole("region", { name: "Related" });
|
||||
}
|
||||
|
||||
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => {
|
||||
/**
|
||||
* The streamed detail carries the same Related as the persisted one: four links
|
||||
* and no control. Pause is resolver-wide and lives in the sidebar alone.
|
||||
*/
|
||||
test("a streamed blocked row's Related carries links only", async () => {
|
||||
await openLive();
|
||||
act(() =>
|
||||
sources[0]!.emit(
|
||||
@@ -468,14 +581,7 @@ test("a streamed blocked row carries the same Pause action as the persisted deta
|
||||
);
|
||||
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();
|
||||
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
|
||||
expect(within(related()).getAllByRole("link")).toHaveLength(4);
|
||||
expect(within(related()).queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
|
||||
* in-place detail a streamed row opens.
|
||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
|
||||
* detail a streamed row opens.
|
||||
*
|
||||
* This subtree is mounted only while the URL says `mode=live`, which is what
|
||||
* closes the EventSource on the way back to history: the connection is a
|
||||
@@ -17,6 +17,7 @@ import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { summarizeEvent } from "@/features/provenance/querySummary";
|
||||
import Dialog from "@/ui/Dialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
@@ -88,6 +89,7 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
dismiss: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
@@ -161,29 +163,6 @@ const styles = stylex.create({
|
||||
textDecorationLine: "underline",
|
||||
textDecorationStyle: "dotted",
|
||||
},
|
||||
detailPanel: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surface,
|
||||
padding: "1rem",
|
||||
},
|
||||
detailBar: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
footnote: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -192,10 +171,6 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
|
||||
const DETAIL_PANEL_ID = "live-query-detail";
|
||||
const DETAIL_LABEL_ID = "live-query-detail-label";
|
||||
|
||||
const PILL_LABELS: Record<StreamStatus, string> = {
|
||||
connecting: "Connecting…",
|
||||
open: "Live",
|
||||
@@ -225,40 +200,15 @@ function StatusPill({ status }: { status: StreamStatus }) {
|
||||
*
|
||||
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
||||
* key would go stale under the reader while they were still reading it, and the
|
||||
* panel would blank out for no reason they could see. The snapshot is the whole
|
||||
* fact — a streamed frame carries its own provenance — so it survives eviction,
|
||||
* a merge and a Freeze/Resume, and closes only when the reader closes it or
|
||||
* leaves live mode.
|
||||
* dialog would blank out or swap under their eyes for no reason they could see.
|
||||
* The snapshot is the whole fact — a streamed frame carries its own provenance
|
||||
* — so it survives eviction, a merge and a Freeze/Resume, and closes only when
|
||||
* the reader closes it or leaves live mode. The stream behind it never stops.
|
||||
*/
|
||||
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
||||
const summary = summarizeEvent(row.event);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The panel opens above the table, behind the trigger in tab order, so a
|
||||
// forward tab from the row would walk past it. Focus moves in on open —
|
||||
// keyed on the row, so choosing a second row moves it again — and the
|
||||
// closer puts it back on the trigger.
|
||||
useEffect(() => {
|
||||
panel.current?.focus();
|
||||
}, [row.key]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panel}
|
||||
id={DETAIL_PANEL_ID}
|
||||
tabIndex={-1}
|
||||
role="group"
|
||||
aria-labelledby={DETAIL_LABEL_ID}
|
||||
{...stylex.props(styles.detailPanel)}
|
||||
>
|
||||
<div {...stylex.props(styles.detailBar)}>
|
||||
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
|
||||
Streamed query
|
||||
</span>
|
||||
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
|
||||
<ProvenanceDetail
|
||||
provenance={row.event}
|
||||
persistedId={null}
|
||||
@@ -268,11 +218,10 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
|
||||
client={summary.client_ip}
|
||||
ts={summary.ts}
|
||||
origin={origin}
|
||||
blocked={row.event.policy.action === "block"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,21 +241,36 @@ export default function LiveActivity({
|
||||
const clientNames = useClientNames();
|
||||
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||
const results = useRef<HTMLDivElement>(null);
|
||||
const restoring = useRef(false);
|
||||
|
||||
/**
|
||||
* Where focus lands when the dialog closes.
|
||||
*
|
||||
* React Aria restores focus itself, but in a `requestAnimationFrame` and
|
||||
* only while focus is still on the body — and its target is the row button,
|
||||
* which the ring may have evicted while the reader was reading. So this runs
|
||||
* in the effect that follows the focus scope's teardown and puts focus on a
|
||||
* connected element first: the row if it is still there, the results region
|
||||
* if it is not. React Aria's deferred pass then finds focus already placed
|
||||
* and does nothing, so the two never fight over it. If a navigation unmounts
|
||||
* this component the effect never runs, which is the right answer — there is
|
||||
* no longer a table to return to.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (selected !== null || !restoring.current) return;
|
||||
restoring.current = false;
|
||||
const from = trigger.current;
|
||||
trigger.current = null;
|
||||
(from?.isConnected === true ? from : results.current)?.focus();
|
||||
}, [selected]);
|
||||
|
||||
function open(row: StreamedRow, from: HTMLButtonElement) {
|
||||
trigger.current = from;
|
||||
restoring.current = true;
|
||||
setSelected(row);
|
||||
}
|
||||
|
||||
// The row that opened the panel takes focus back, unless the ring has
|
||||
// already evicted it: a detached button cannot be focused, and the browser
|
||||
// falls back to the document, which is the best available answer.
|
||||
function close() {
|
||||
setSelected(null);
|
||||
trigger.current?.focus();
|
||||
trigger.current = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
@@ -362,8 +326,18 @@ export default function LiveActivity({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
|
||||
|
||||
{/*
|
||||
* The region is the anchor focus falls back to when the row that
|
||||
* opened the dialog is gone, so it is rendered unconditionally: an
|
||||
* anchor that disappears with the last row is no anchor at all.
|
||||
*/}
|
||||
<div
|
||||
ref={results}
|
||||
tabIndex={-1}
|
||||
role="region"
|
||||
aria-label="Live queries"
|
||||
{...stylex.props(shared.focusRing)}
|
||||
>
|
||||
{live.rows.length === 0 ? (
|
||||
live.status !== "capped" && (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
@@ -390,10 +364,7 @@ export default function LiveActivity({
|
||||
row.kind === "streamed" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={selected?.key === row.key}
|
||||
aria-controls={
|
||||
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
|
||||
}
|
||||
aria-haspopup="dialog"
|
||||
onClick={(event) => open(row, event.currentTarget)}
|
||||
{...stylex.props(
|
||||
styles.domainButton,
|
||||
@@ -422,11 +393,14 @@ export default function LiveActivity({
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footnote)}>
|
||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
||||
{capacity} kept).
|
||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
|
||||
last {capacity} kept).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
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";
|
||||
@@ -24,15 +23,9 @@ interface Props {
|
||||
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) {
|
||||
export default function RelatedActions({ domain, client, ts, origin }: Props) {
|
||||
const bounds = relatedBounds(ts, origin);
|
||||
const window = diagnosticsBounds(ts);
|
||||
return (
|
||||
@@ -61,7 +54,6 @@ export default function RelatedActions({ domain, client, ts, origin, blocked }:
|
||||
>
|
||||
Diagnostics around this query
|
||||
</Link>
|
||||
{blocked && <PauseControl />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,16 +18,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
@@ -67,8 +61,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
|
||||
<Dialog title={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||
<form
|
||||
{...stylex.props(styles.form)}
|
||||
onSubmit={(event) => {
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { ClientName, type ClientNames } from "./clientNames";
|
||||
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||
import { BASE, CLIENTS, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function clientRow(ip: string): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr");
|
||||
@@ -73,6 +87,39 @@ test("the address links to the client's detail page", async () => {
|
||||
expect(link.getAttribute("href")).toBe("/clients/1");
|
||||
});
|
||||
|
||||
test("delete asks first, naming the row, and the confirmation carries out the delete", async () => {
|
||||
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
// The operator has to be able to tell from the dialog alone which row this is.
|
||||
expect(within(dialog).getByText(/kids-tablet\.lan \(192\.168\.1\.11\)/)).toBeTruthy();
|
||||
expect(within(dialog).getByText(/re-materialize on their next DNS query/)).toBeTruthy();
|
||||
// Asking is not deleting.
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2")),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
});
|
||||
|
||||
test("cancelling the confirmation keeps the client", async () => {
|
||||
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||
});
|
||||
|
||||
test("shows the DNS-activity empty state when there are no clients", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||
|
||||
@@ -140,9 +187,15 @@ test("an unknown group id filters to nothing and offers a way out", async () =>
|
||||
expect(router.state.location.search).toEqual({});
|
||||
});
|
||||
|
||||
// Both statuses lock the declared delete, but only file authority proves the
|
||||
// file declares the row; the anchors keep the two sentences apart.
|
||||
const DECLARED_NOTE = /^This client is declared in the configuration file/;
|
||||
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
|
||||
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
|
||||
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/);
|
||||
// The settled sentence, not the tag: "Locked" is already on screen while
|
||||
// authority is pending, so waiting on it would not wait for this status.
|
||||
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
|
||||
@@ -150,6 +203,34 @@ test("file mode drops every edit affordance and keeps the observed delete live (
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
// Why the locked Delete will not answer, in visible text and exactly once:
|
||||
// per row it would repeat down the whole page, and on the button it was a
|
||||
// title that a keyboard and a touch screen never reached.
|
||||
expect(screen.getAllByText(DECLARED_NOTE, { selector: "p" })).toHaveLength(1);
|
||||
expect(within(declared).queryByText(DECLARED_NOTE, { selector: "p" })).toBeNull();
|
||||
// The description stays on the button itself too, for a reader on that control.
|
||||
const locked = within(declared).getByRole("button", { name: "Delete" });
|
||||
expect(document.getElementById(locked.getAttribute("aria-describedby") ?? "")?.textContent).toMatch(DECLARED_NOTE);
|
||||
});
|
||||
|
||||
test("an all-observed page still says why Edit is gone, with no delete note to carry it", async () => {
|
||||
// Every row observed, so no Delete is locked. The edit lock is still real, and
|
||||
// "Locked" appearing with nothing to explain it is the failure this guards.
|
||||
const observedOnly = { clients: [CLIENTS.clients[1]] };
|
||||
await renderClientsPage({
|
||||
...BASE,
|
||||
"GET /api/clients": observedOnly,
|
||||
"GET /api/config/status": MANAGED_FILE,
|
||||
});
|
||||
// The settled sentence is both the anchor and the assertion: it is the whole
|
||||
// explanation for the missing Edit action.
|
||||
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
|
||||
expect(await screen.findAllByText("Locked")).not.toHaveLength(0);
|
||||
|
||||
// The delete sentence belongs only to a row that has one.
|
||||
expect(screen.queryByText(DECLARED_NOTE)).toBeNull();
|
||||
expect((screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
|
||||
@@ -170,7 +251,7 @@ test("file mode renders network assignments with no mutation control at all (R2-
|
||||
|
||||
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
|
||||
await screen.findAllByLabelText(/Configuration status unavailable/);
|
||||
await screen.findAllByText(/^Configuration status unavailable/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||
@@ -186,10 +267,6 @@ test("a failed config status exposes no configuration mutation, and still delete
|
||||
// confirmation is already open. `undefined` is the failed status: the fetch stub
|
||||
// answers 404 for a key it does not hold.
|
||||
//
|
||||
// Both statuses lock the declared delete, but only file authority proves the
|
||||
// file declares the row; the anchors keep the two sentences apart.
|
||||
const DECLARED_NOTE = /^This client is declared in the configuration file/;
|
||||
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
|
||||
describe.each([
|
||||
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
|
||||
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
|
||||
@@ -212,13 +289,13 @@ describe.each([
|
||||
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
|
||||
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
|
||||
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||
expect(within(dialog).getByText("Locked")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
test("locks an open declared-client delete confirmation and leaves cancel working", async () => {
|
||||
test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => {
|
||||
const map = { ...BASE };
|
||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
// The Edit affordance appearing is the proof authority resolved to
|
||||
@@ -227,23 +304,68 @@ describe.each([
|
||||
|
||||
const declared = clientRow("192.168.1.10");
|
||||
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
|
||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
|
||||
const confirming = clientRow("192.168.1.10");
|
||||
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
|
||||
expect(within(confirming).getByText(lockNote)).toBeTruthy();
|
||||
expect(within(confirming).queryByText(otherNote)).toBeNull();
|
||||
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||
// The dialog stays put. Closing it would throw focus at the Delete button
|
||||
// the same turn disabled, and the reason would survive only as a title
|
||||
// attribute; here the reason is the dialog's own message.
|
||||
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
|
||||
expect(within(dialog).getByText(lockNote)).toBeTruthy();
|
||||
expect(within(dialog).queryByText(otherNote)).toBeNull();
|
||||
expect(within(dialog).getByText("Locked")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||
});
|
||||
|
||||
test("a cancelled confirmation stays closed when authority comes back", async () => {
|
||||
const map = { ...BASE };
|
||||
const { queryClient } = await renderClientsPage(map);
|
||||
await unlockedEdit(0);
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
|
||||
// A poll that fails and then recovers must not raise a destructive
|
||||
// question the operator already answered.
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0));
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
});
|
||||
|
||||
test("the confirm action comes back when authority does, without a second prompt", async () => {
|
||||
const map = { ...BASE, "DELETE /api/clients/1": {} };
|
||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
await unlockedEdit(0);
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
|
||||
|
||||
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
|
||||
|
||||
// The question was never withdrawn, so the answer returns to the same
|
||||
// dialog rather than asking the operator to start again.
|
||||
const confirm = await within(dialog).findByRole("button", { name: "Delete" });
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
|
||||
const map = { ...BASE, "DELETE /api/clients/2": {} };
|
||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
@@ -253,10 +375,11 @@ describe.each([
|
||||
|
||||
const observed = clientRow("192.168.1.11");
|
||||
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
@@ -271,7 +394,7 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
|
||||
// The status request never settles, so authority stays pending for the whole
|
||||
// test: nothing configuration owns may be offered on that guess.
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
|
||||
await screen.findAllByLabelText(/Checking which configuration source/);
|
||||
await screen.findAllByText(/^Checking which configuration source/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||
@@ -280,3 +403,66 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("a save-time problem marks the input it is about and describes it", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||
|
||||
// Only the offending input is marked; the priority beside it is untouched.
|
||||
const priority = screen.getByLabelText("Priority for range 1");
|
||||
expect(priority.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(priority.getAttribute("aria-describedby")).toBeNull();
|
||||
|
||||
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||
fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe(
|
||||
"Row 1: priority must be a whole number.",
|
||||
);
|
||||
});
|
||||
|
||||
test("removing a row drops the message rather than moving it to another input", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
// Row 1 is the offending one, so removing it is what would slide the stale
|
||||
// index onto row 2 — an input that validated cleanly.
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
|
||||
fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||
|
||||
const section = assignmentsSection();
|
||||
fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement);
|
||||
|
||||
const survivor = screen.getByLabelText("Range 1") as HTMLInputElement;
|
||||
expect(survivor.value).toBe("10.0.0.0/8");
|
||||
expect(survivor.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(survivor.getAttribute("aria-describedby")).toBeNull();
|
||||
expect(within(section).queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByLabelText("Range 2")).toBeNull();
|
||||
});
|
||||
|
||||
test("editing a row clears the message it was about", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(within(assignmentsSection()).queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ import ClientEditDialog from "./ClientEditDialog";
|
||||
import NetworkAssignments from "./NetworkAssignments";
|
||||
import { ClientDisplayName } from "./clientIdentity";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import ConfigLockIndicator, { lockReason } from "@/features/configuration/ConfigLockIndicator";
|
||||
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -24,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
|
||||
* alone cannot say which operator surface set the row — so the sentence names
|
||||
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
|
||||
*/
|
||||
/** The one note every locked Delete on this page describes itself with. */
|
||||
const DELETE_LOCK_NOTE_ID = "clients-delete-locked-note";
|
||||
|
||||
function declaredDeleteNote(authority: Authority): string {
|
||||
if (authority.state === "resolved") {
|
||||
return "This client is declared in the configuration file; remove it there and restart.";
|
||||
@@ -31,6 +35,16 @@ function declaredDeleteNote(authority: Authority): string {
|
||||
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
|
||||
}
|
||||
|
||||
/**
|
||||
* How the confirmation names the row. The address is always there and always
|
||||
* unique, so it carries the sentence; a name the operator recognizes leads when
|
||||
* the row has one.
|
||||
*/
|
||||
function clientLabel(client: Client): string {
|
||||
const name = client.name !== "" ? client.name : client.learned_name;
|
||||
return name === "" ? client.ip : `${name} (${client.ip})`;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
@@ -41,6 +55,15 @@ const styles = stylex.create({
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
lockNote: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
filterBar: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
@@ -79,23 +102,11 @@ const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
confirmGroup: {
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
actionGroup: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
@@ -112,7 +123,7 @@ export default function ClientsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||
const [editing, setEditing] = useState<Client | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const authority = useAuthority();
|
||||
|
||||
@@ -122,6 +133,17 @@ export default function ClientsPage() {
|
||||
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
|
||||
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
|
||||
|
||||
// Authority is polled, so it can turn while the confirmation is open. The
|
||||
// dialog reads it on every render rather than trusting the state that opened
|
||||
// it, and withdraws the answer that would now fail instead of withdrawing the
|
||||
// question: the operator is told why, and only they close the dialog.
|
||||
const deleteLocked = pendingDelete !== null && readOnly && pendingDelete.hand_edited;
|
||||
|
||||
// The reason a locked Delete will not answer, printed once above the table.
|
||||
// Per row it would repeat down the whole page; on the button it was a `title`
|
||||
// that a keyboard and a touch screen never reached.
|
||||
const deletesLocked = readOnly && rows.some((client) => client.hand_edited);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||
@@ -137,6 +159,15 @@ export default function ClientsPage() {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{/* The whole explanation for this page's locks, printed once. Per row it
|
||||
would repeat down the table; on the controls it was a `title` that a
|
||||
keyboard and a touch screen never reached. */}
|
||||
{readOnly && (
|
||||
<div {...stylex.props(styles.lockNote)}>
|
||||
<p>{lockReason(authority)}.</p>
|
||||
{deletesLocked && <p id={DELETE_LOCK_NOTE_ID}>{declaredDeleteNote(authority)}</p>}
|
||||
</div>
|
||||
)}
|
||||
{clients.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||
@@ -178,48 +209,11 @@ export default function ClientsPage() {
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.right)}>
|
||||
{confirmingId === client.id ? (
|
||||
<span {...stylex.props(styles.confirmGroup)}>
|
||||
{/* Authority is polled, so it can turn while a confirmation
|
||||
sits open. The confirm path reads it on every render
|
||||
rather than trusting the state that opened it. */}
|
||||
<span {...stylex.props(styles.note)}>
|
||||
{readOnly && client.hand_edited
|
||||
? declaredDeleteNote(authority)
|
||||
: "Deleted clients re-materialize on their next DNS query."}
|
||||
</span>
|
||||
{readOnly && client.hand_edited ? (
|
||||
<ConfigLockIndicator />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmingId(null);
|
||||
deleteMutation.mutate(client.id);
|
||||
}}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(null)}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
{/* Naming a client writes configuration, so the affordance is
|
||||
absent — not disabled — wherever the write cannot land. */}
|
||||
{readOnly ? (
|
||||
<ConfigLockIndicator />
|
||||
<ConfigLockIndicator compact />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -231,12 +225,10 @@ export default function ClientsPage() {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
onClick={() => setPendingDelete(client)}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? declaredDeleteNote(authority)
|
||||
: undefined
|
||||
aria-describedby={
|
||||
readOnly && client.hand_edited ? DELETE_LOCK_NOTE_ID : undefined
|
||||
}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
@@ -248,7 +240,6 @@ export default function ClientsPage() {
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -256,6 +247,24 @@ export default function ClientsPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete client"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: deleteLocked
|
||||
? declaredDeleteNote(authority)
|
||||
: `Delete ${clientLabel(pendingDelete)}? Deleted clients re-materialize on their next DNS query.`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
lock={deleteLocked ? <ConfigLockIndicator /> : undefined}
|
||||
onConfirm={() => {
|
||||
if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
<InlineError error={deleteMutation.error} />
|
||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
||||
|
||||
@@ -4,7 +4,15 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||
import type { ClientPrefix, Group } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||
import {
|
||||
firstProblem,
|
||||
initPrefixEditor,
|
||||
isDirty,
|
||||
prefixEditorReducer,
|
||||
toInputs,
|
||||
type PrefixEditorAction,
|
||||
type PrefixProblem,
|
||||
} from "./prefixEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import AuthorityGate from "@/features/configuration/AuthorityGate";
|
||||
import Select from "@/ui/Select";
|
||||
@@ -16,6 +24,9 @@ interface Props {
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
/** The editor is rendered once per page, so the message can hold a fixed id. */
|
||||
const VALIDATION_ID = "network-assignments-validation";
|
||||
|
||||
const styles = stylex.create({
|
||||
section: {
|
||||
marginTop: "2.5rem",
|
||||
@@ -65,6 +76,7 @@ const styles = stylex.create({
|
||||
width: "5rem",
|
||||
},
|
||||
removeButton: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -161,12 +173,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
||||
function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<PrefixProblem | null>(null);
|
||||
const dirty = isDirty(state);
|
||||
const fallbackGroupId = defaultGroupId(groups);
|
||||
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
||||
|
||||
// A problem names a row by position, and every action here can move, add or
|
||||
// delete a position. The message describes the rows Save read, so it dies
|
||||
// with them rather than drifting onto whatever row inherits the index.
|
||||
const dispatch = (action: PrefixEditorAction) => {
|
||||
setValidation(null);
|
||||
apply(action);
|
||||
};
|
||||
|
||||
/** True for the one input the current message is about; nothing else is marked. */
|
||||
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
|
||||
validation !== null && validation.index === index && validation.field === field ? true : undefined;
|
||||
|
||||
const save = () => {
|
||||
const problem = firstProblem(state.rows);
|
||||
setValidation(problem);
|
||||
@@ -187,6 +211,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Range ${index + 1}`}
|
||||
aria-invalid={invalid(index, "prefix")}
|
||||
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
|
||||
placeholder="192.168.1.0/24"
|
||||
value={row.prefix}
|
||||
onChange={(event) =>
|
||||
@@ -207,6 +233,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={`Priority for range ${index + 1}`}
|
||||
aria-invalid={invalid(index, "priority")}
|
||||
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
|
||||
placeholder="100"
|
||||
value={row.priority}
|
||||
onChange={(event) =>
|
||||
@@ -226,8 +254,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
</ul>
|
||||
)}
|
||||
{validation !== null && (
|
||||
<p role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation}
|
||||
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation.message}
|
||||
</p>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
@@ -250,10 +278,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValidation(null);
|
||||
dispatch({ type: "reset", prefixes });
|
||||
}}
|
||||
onClick={() => dispatch({ type: "reset", prefixes })}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Discard changes
|
||||
|
||||
@@ -16,6 +16,14 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { clientsQuery } from "@/lib/queries";
|
||||
import type { Client } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
/** Secondary to the name it qualifies, and never the only thing in the cell. */
|
||||
address: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
|
||||
|
||||
@@ -53,11 +61,13 @@ export function clientLabel(ip: string, names: ClientNames): { text: string; lea
|
||||
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
|
||||
const label = clientLabel(ip, names);
|
||||
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
||||
// The name replaces the address on screen, so the address stays reachable
|
||||
// as the tooltip rather than disappearing from the row entirely.
|
||||
// The name replaces the address, so the address follows it as real text that
|
||||
// anyone can read and copy. A `title` carried it before, which reaches
|
||||
// neither a keyboard nor a touch screen.
|
||||
return (
|
||||
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
|
||||
<span {...stylex.props(label.learned && shared.learnedName)}>
|
||||
{label.text}
|
||||
<span {...stylex.props(styles.address)}> ({ip})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,11 +76,15 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
|
||||
|
||||
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
||||
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
|
||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toEqual({
|
||||
index: 0,
|
||||
field: "prefix",
|
||||
message: "Row 1: prefix is required.",
|
||||
});
|
||||
expect(
|
||||
firstProblem([
|
||||
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
||||
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
||||
]),
|
||||
).toBe("Row 2: priority must be a whole number.");
|
||||
).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
|
||||
});
|
||||
|
||||
@@ -55,11 +55,20 @@ export function isDirty(state: PrefixEditorState): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export function firstProblem(rows: PrefixRow[]): string | null {
|
||||
for (const [i, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
|
||||
/** Which input the message is about, so the editor can point that input at it. */
|
||||
export interface PrefixProblem {
|
||||
index: number;
|
||||
field: "prefix" | "priority";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "")
|
||||
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
|
||||
const priority = row.priority.trim();
|
||||
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
|
||||
if (priority !== "" && !/^\d+$/.test(priority))
|
||||
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -61,17 +61,20 @@ test("the lock is silent when the database owns the configuration", async () =>
|
||||
test("under file authority the lock names the file, in words a reader hears", async () => {
|
||||
renderIndicator(MANAGED_FILE);
|
||||
|
||||
// The reason is visible text beside the tag, not a title and not a label: a
|
||||
// tooltip reaches neither a keyboard nor a touch screen, and screen-reader-only
|
||||
// text is the same failure pointed the other way.
|
||||
const lock = await screen.findByText("Locked");
|
||||
await waitFor(() =>
|
||||
expect(lock.getAttribute("aria-label")).toBe(
|
||||
`Locked. Managed by ${CONFIG_PATH}; edit the file and restart nxdns.`,
|
||||
),
|
||||
expect(screen.getByText(`Managed by ${CONFIG_PATH}; edit the file and restart nxdns`)).toBeTruthy(),
|
||||
);
|
||||
expect(lock.getAttribute("title")).toBeNull();
|
||||
expect(lock.getAttribute("aria-label")).toBeNull();
|
||||
});
|
||||
|
||||
test("an unanswered status still locks, and says that is why", async () => {
|
||||
renderIndicator("failed");
|
||||
|
||||
const lock = await screen.findByText("Locked");
|
||||
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable"));
|
||||
await screen.findByText("Locked");
|
||||
await waitFor(() => expect(screen.getByText(/^Configuration status unavailable/)).toBeTruthy());
|
||||
});
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { useAuthority } from "./authority";
|
||||
import { useAuthority, type Authority } from "./authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
row: {
|
||||
display: "inline-flex",
|
||||
alignItems: "baseline",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
/** The sentence is secondary to the word, and wraps rather than stretching a row. */
|
||||
reason: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tag: {
|
||||
marginLeft: "0.5rem",
|
||||
borderWidth: 1,
|
||||
@@ -26,20 +38,34 @@ const styles = stylex.create({
|
||||
* The word is real text, not colour or an icon, so a screen reader announces
|
||||
* the reason the control will not answer.
|
||||
*/
|
||||
export default function ConfigLockIndicator() {
|
||||
/**
|
||||
* Why a configuration control will not answer. Exported because a page that
|
||||
* shows the compact tag has to print this sentence itself, once, somewhere the
|
||||
* tag can point at.
|
||||
*/
|
||||
export function lockReason(authority: Authority): string {
|
||||
if (authority.state === "pending") return "Checking which configuration source this server obeys";
|
||||
if (authority.state === "failed") return "Configuration status unavailable, so edits are held back";
|
||||
return `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
|
||||
}
|
||||
|
||||
export default function ConfigLockIndicator({ compact = false }: { compact?: boolean }) {
|
||||
const authority = useAuthority();
|
||||
if (authority.state === "resolved" && authority.status.authority === "database") return null;
|
||||
|
||||
const reason =
|
||||
authority.state === "pending"
|
||||
? "Checking which configuration source this server obeys"
|
||||
: authority.state === "failed"
|
||||
? "Configuration status unavailable, so edits are held back"
|
||||
: `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
|
||||
const reason = lockReason(authority);
|
||||
|
||||
// A compact caller has no room for the sentence and must print it once
|
||||
// nearby instead: the Clients table would otherwise repeat it down every row.
|
||||
if (compact) return <span {...stylex.props(styles.tag)}>Locked</span>;
|
||||
|
||||
// Everywhere else the reason is visible text rather than a `title` or an
|
||||
// `aria-label`. A tooltip reaches neither a keyboard nor a touch screen, and
|
||||
// screen-reader-only text is the same failure pointed the other way.
|
||||
return (
|
||||
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}>
|
||||
Locked
|
||||
<span {...stylex.props(styles.row)}>
|
||||
<span {...stylex.props(styles.tag)}>Locked</span>
|
||||
<span {...stylex.props(styles.reason)}>{reason}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||
import type { Blocklist } from "@/lib/types";
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
import { sameSet } from "./sourceSet";
|
||||
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -28,13 +29,6 @@ const styles = stylex.create({
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
buttonRow: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
@@ -66,21 +60,22 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.root)}>
|
||||
{/* The section's own "Assigned sources" heading is the visible label; a
|
||||
Label here would put the same words on screen twice. Ids cross the
|
||||
React Aria boundary as strings, the same convention as Select. */}
|
||||
<CheckboxGroup
|
||||
aria-label="Assigned sources"
|
||||
value={current.map(String)}
|
||||
onChange={(values) => setSelected(values.map(Number).sort((a, b) => a - b))}
|
||||
>
|
||||
<ul {...stylex.props(styles.list)}>
|
||||
{blocklists.map((blocklist) => (
|
||||
<li key={blocklist.id}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={current.includes(blocklist.id)}
|
||||
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
{blocklist.name}
|
||||
</label>
|
||||
<Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CheckboxGroup>
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import DefinitionList from "@/ui/DefinitionList";
|
||||
import Select from "@/ui/Select";
|
||||
import Switch from "@/ui/Switch";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import AuthorityGate from "./AuthorityGate";
|
||||
@@ -68,13 +69,6 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
spacer: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
@@ -355,21 +349,15 @@ function GroupDetailEditable({ group }: { group: Group }) {
|
||||
)}
|
||||
|
||||
<div {...stylex.props(styles.controlRow)}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={group.safe_search}
|
||||
disabled={update.isPending}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
onChange={(event) =>
|
||||
update.mutate({
|
||||
id: group.id,
|
||||
input: { name: group.name, safe_search: event.target.checked },
|
||||
})
|
||||
<Switch
|
||||
isSelected={group.safe_search}
|
||||
isDisabled={update.isPending}
|
||||
onChange={(safeSearch) =>
|
||||
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
|
||||
}
|
||||
/>
|
||||
>
|
||||
Safe search
|
||||
</label>
|
||||
</Switch>
|
||||
<span {...stylex.props(styles.spacer)}>
|
||||
{!renaming && (
|
||||
<button
|
||||
|
||||
@@ -46,7 +46,7 @@ test("another group can be renamed and deleted, and carries its safe-search stat
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
@@ -60,7 +60,7 @@ test("toggling safe search resends the whole group row", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));
|
||||
|
||||
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||
expect(writes("PUT")[0]).toMatchObject({
|
||||
@@ -89,6 +89,31 @@ test("the source assignment saves the full set via PUT", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("the source checkboxes form one named group, and each carries its own state", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
// The section heading names the group; the boxes belong to it rather than
|
||||
// sitting loose beside the group's other checkboxes.
|
||||
const group = await screen.findByRole("group", { name: "Assigned sources" });
|
||||
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
|
||||
expect(ads.checked).toBe(false);
|
||||
// Safe search is the group's own field, not one of its sources, and it is a
|
||||
// switch rather than a checkbox because it applies the moment it moves.
|
||||
expect(within(group).queryByRole("switch")).toBeNull();
|
||||
expect(screen.getByRole("switch", { name: "Safe search" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(ads);
|
||||
await waitFor(() => expect(ads.checked).toBe(true));
|
||||
|
||||
// Discard returns the group to the server's set rather than clearing it.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||
await waitFor(() =>
|
||||
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
|
||||
);
|
||||
expect(writes("PUT")).toEqual([]);
|
||||
});
|
||||
|
||||
test("only the selected group's rules are listed", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
@@ -212,11 +237,33 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
|
||||
expect(
|
||||
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
||||
).toBeTruthy();
|
||||
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
|
||||
// Switches, not checkboxes: the row applies the moment it moves, and the role
|
||||
// is what tells a screen reader so.
|
||||
expect((screen.getByRole("switch", { name: "Ads enabled" }) as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByRole("switch", { name: "Trackers enabled" }) as HTMLInputElement).checked).toBe(false);
|
||||
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a source switch resends the whole row immediately, with no Save step", async () => {
|
||||
calls = stubApi(DATABASE);
|
||||
await renderPage("/configuration/protection?tab=sources", "Protection");
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch", { name: "Trackers enabled" }));
|
||||
|
||||
// No Save button stands between the switch and the write: one click, one PUT.
|
||||
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||
expect(writes("PUT")[0]).toMatchObject({
|
||||
url: "/api/blocklists/2",
|
||||
// The whole row goes back, not a patch of the one field that moved.
|
||||
body: {
|
||||
url: "https://example.com/trackers.txt",
|
||||
name: "Trackers",
|
||||
enabled: true,
|
||||
is_suggested: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Update now says it started, and says nothing once it succeeds", async () => {
|
||||
let release: ((response: Response) => void) | null = null;
|
||||
calls = stubApi(DATABASE, {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import Switch from "@/ui/Switch";
|
||||
import AuthorityGate from "./AuthorityGate";
|
||||
import BlocklistForm from "./BlocklistForm";
|
||||
import FileModeNote from "./FileModeNote";
|
||||
@@ -249,13 +250,11 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Switch
|
||||
aria-label={`${b.name} enabled`}
|
||||
checked={b.enabled}
|
||||
disabled={toggle.isPending}
|
||||
isSelected={b.enabled}
|
||||
isDisabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(b)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
|
||||
import { DATABASE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
|
||||
|
||||
/**
|
||||
* Resolution in database mode: the upstream pool, the local records and the
|
||||
@@ -37,7 +37,7 @@ test("the upstream pool is the default tab and lists every field", async () => {
|
||||
expect((screen.getByLabelText("udp://1.1.1.1:53 enabled") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("tls://9.9.9.9:853 enabled") as HTMLInputElement).checked).toBe(false);
|
||||
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
|
||||
expect(screen.getByText(/takes effect at the next restart/)).toBeTruthy();
|
||||
expect(screen.getByText(/applies to the next query/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("adding an upstream posts every field", async () => {
|
||||
@@ -56,24 +56,18 @@ test("adding an upstream posts every field", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("an upstream write re-reads the config status, and the shell states the pending restart", async () => {
|
||||
// The client never decides a restart is owed: the server sets the flag, and
|
||||
// the mutation's invalidation is only what makes the page ask again.
|
||||
let restartPending = false;
|
||||
await openResolution(undefined, {
|
||||
responses: { "GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }) },
|
||||
onWrite: () => {
|
||||
restartPending = true;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
test("an upstream write applies live, so the tab says nothing about a restart", async () => {
|
||||
// The server rebuilds the pool on the write and echoes `restart_required:
|
||||
// false`, so `restart_pending` stays down and silence is the whole report.
|
||||
await openResolution(undefined, { responses: { "GET /api/config/status": () => DATABASE } });
|
||||
await screen.findByRole("heading", { name: "Add upstream" });
|
||||
expect(screen.queryByText(/Restart nxdns to apply them/)).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
|
||||
|
||||
await screen.findByText(/Saved changes are not running yet\. Restart nxdns to apply them\./);
|
||||
await waitFor(() => expect(writes()).toHaveLength(1));
|
||||
expect(screen.queryByText(/Restart nxdns to apply them/)).toBeNull();
|
||||
expect(contentArea().textContent).not.toMatch(/restart/i);
|
||||
});
|
||||
|
||||
test("toggling enabled resends the whole row", async () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ import { styles as config } from "./styles";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
/** The form is rendered once per page, so the message can hold a fixed id. */
|
||||
const MISMATCH_ID = "web.password_mismatch";
|
||||
|
||||
const styles = stylex.create({
|
||||
form: {
|
||||
marginTop: "1rem",
|
||||
@@ -100,6 +103,7 @@ const styles = stylex.create({
|
||||
gap: "0.75rem",
|
||||
},
|
||||
save: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "1rem",
|
||||
@@ -174,12 +178,15 @@ function FieldRow({
|
||||
}
|
||||
if (def.kind === "number") {
|
||||
const numeric = value as number;
|
||||
// An empty or unparseable number reads back as NaN. The form already refuses
|
||||
// to submit on it; this is what says so to a screen reader.
|
||||
return (
|
||||
<div {...stylex.props(styles.field)}>
|
||||
<FieldLabel id={id} text={def.key} restart={restart} />
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
aria-invalid={Number.isNaN(numeric) || undefined}
|
||||
value={Number.isNaN(numeric) ? "" : numeric}
|
||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -285,6 +292,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
id="web.password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={passwordsMismatch || undefined}
|
||||
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -298,6 +307,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
id="web.password_confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={passwordsMismatch || undefined}
|
||||
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -310,7 +321,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
</p>
|
||||
)}
|
||||
{passwordsMismatch && (
|
||||
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||
Passwords do not match.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
import { DATABASE, MANAGED_FILE, baseSettings, renderPage, stubApi } from "./testFixtures";
|
||||
import { DATABASE, MANAGED_FILE, RESTART_REQUIRED_KEYS, baseSettings, renderPage, stubApi } from "./testFixtures";
|
||||
|
||||
/**
|
||||
* System in database mode: the settings form, its diff contract, and the
|
||||
* certificate reload that is a runtime action under both authorities.
|
||||
*/
|
||||
|
||||
// `logging.level` is enum-backed, so the list covers both field renderings: an
|
||||
// input whose label carries the mark, and a `Select` that cannot.
|
||||
const RESTART_KEYS = ["dns.port", "web.port", "logging.level"];
|
||||
// What the server actually reports: the listener binds and `web.enabled`.
|
||||
// Every other key applies live, so it carries no mark at all.
|
||||
const RESTART_KEYS = RESTART_REQUIRED_KEYS;
|
||||
|
||||
let stored: Settings;
|
||||
let putBodies: SettingsPatch[];
|
||||
@@ -32,10 +32,10 @@ function applyPatch(patch: SettingsPatch): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors settings.zig: a patch touching only `web.password` applies live. */
|
||||
function needsRestart(patch: SettingsPatch): boolean {
|
||||
/** Mirrors apply.zig's table: only a listed key leaves the server owing a restart. */
|
||||
function needsRestart(patch: SettingsPatch, keys: readonly string[]): boolean {
|
||||
return Object.entries(patch).some(([section, fields]) =>
|
||||
Object.keys(fields as Record<string, unknown>).some((key) => !(section === "web" && key === "password")),
|
||||
Object.keys(fields as Record<string, unknown>).some((key) => keys.includes(`${section}.${key}`)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function openSystem() {
|
||||
async function openSystem(restartKeys: readonly string[] = RESTART_KEYS) {
|
||||
stubApi(DATABASE, {
|
||||
responses: {
|
||||
"GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }),
|
||||
"GET /api/settings": () => ({ settings: stored, restart_required: RESTART_KEYS }),
|
||||
"GET /api/settings": () => ({ settings: stored, restart_required: restartKeys }),
|
||||
},
|
||||
onWrite: (call) => {
|
||||
if (call.url !== "/api/settings") return null;
|
||||
@@ -62,8 +62,8 @@ async function openSystem() {
|
||||
putBodies.push(patch);
|
||||
if (putResponse !== null) return putResponse();
|
||||
applyPatch(patch);
|
||||
if (needsRestart(patch)) restartPending = true;
|
||||
return json({ settings: stored, restart_required: RESTART_KEYS });
|
||||
if (needsRestart(patch, restartKeys)) restartPending = true;
|
||||
return json({ settings: stored, restart_required: restartKeys });
|
||||
},
|
||||
});
|
||||
const router = await renderPage("/configuration/system", "System");
|
||||
@@ -103,15 +103,50 @@ test("a changed field enables Save and the PUT body is exactly the diff", async
|
||||
test("a restart-required key is marked as one, from the envelope's list", async () => {
|
||||
await openSystem();
|
||||
|
||||
expect(within(screen.getByRole("group", { name: "DNS" })).getByText("needs restart")).toBeTruthy();
|
||||
// `rate_limit` is not on the list, so it carries no mark.
|
||||
// The DNS binds and the port are the section's whole share of the list;
|
||||
// `rate_limit` and `rate_window_seconds` apply live and carry no mark.
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
expect(within(dns).getAllByText("needs restart")).toHaveLength(3);
|
||||
const cache = screen.getByRole("group", { name: "Cache" });
|
||||
expect(within(cache).queryByText("needs restart")).toBeNull();
|
||||
});
|
||||
|
||||
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
|
||||
test("every key the server applies live is drawn without restart messaging", async () => {
|
||||
await openSystem();
|
||||
|
||||
// Silence is the report for a live key: no mark on the field, and editing
|
||||
// one owes nothing afterwards either.
|
||||
for (const title of ["Upstream", "Blocking", "Cache", "EDNS", "Logging", "Disk", "Blocklist Update"]) {
|
||||
const section = screen.getByRole("group", { name: title });
|
||||
expect(within(section).queryByText("needs restart")).toBeNull();
|
||||
}
|
||||
|
||||
const logging = screen.getByRole("group", { name: "Logging" });
|
||||
fireEvent.change(within(logging).getByLabelText("retention_days"), { target: { value: "14" } });
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ logging: { retention_days: 14 } });
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
||||
expect(restartNotice()).toBeNull();
|
||||
});
|
||||
|
||||
test("a port edit still owes a restart, and the shell says so", async () => {
|
||||
await openSystem();
|
||||
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
await screen.findByText(/Saved changes are not running yet/);
|
||||
});
|
||||
|
||||
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
|
||||
// No shipped restart-required key is enum-backed, but the list is the
|
||||
// server's to change, so the `Select` rendering is pinned against one.
|
||||
await openSystem(["logging.level"]);
|
||||
|
||||
const logging = screen.getByRole("group", { name: "Logging" });
|
||||
// `logging.level` is a Select and `logging.output` is not on the list, so
|
||||
// exactly one mark belongs to this section.
|
||||
@@ -141,12 +176,52 @@ test("enum and boolean fields diff as their own types", async () => {
|
||||
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
||||
});
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
test("clearing a number field disables Save instead of sending NaN", async () => {
|
||||
await openSystem();
|
||||
|
||||
const cache = screen.getByRole("group", { name: "Cache" });
|
||||
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
|
||||
const size = within(cache).getByLabelText("size");
|
||||
fireEvent.change(size, { target: { value: "" } });
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
// The refusal is on the field itself, not only on the Save button.
|
||||
expect(size.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
fireEvent.change(size, { target: { value: "512" } });
|
||||
expect(size.getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
|
||||
test("the mismatch message is attached to both password inputs", async () => {
|
||||
await openSystem();
|
||||
|
||||
const web = screen.getByRole("group", { name: "Web" });
|
||||
const passwordInput = within(web).getByLabelText("password");
|
||||
const confirmInput = within(web).getByLabelText("confirm password");
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
|
||||
for (const input of [passwordInput, confirmInput]) {
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(input)).toBe("Passwords do not match.");
|
||||
}
|
||||
|
||||
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
|
||||
for (const input of [passwordInput, confirmInput]) {
|
||||
expect(input.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(input.getAttribute("aria-describedby")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
|
||||
@@ -176,7 +251,7 @@ test("password flow: note shown, confirm required, PUT sends web.password, no re
|
||||
expect(restartNotice()).toBeNull();
|
||||
});
|
||||
|
||||
test("a mixed patch makes the server owe a restart, and the shell says so", async () => {
|
||||
test("a patch of live keys alone leaves the server owing nothing", async () => {
|
||||
await openSystem();
|
||||
|
||||
const web = screen.getByRole("group", { name: "Web" });
|
||||
@@ -187,7 +262,8 @@ test("a mixed patch makes the server owe a restart, and the shell says so", asyn
|
||||
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
|
||||
await screen.findByText(/Saved changes are not running yet/);
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
||||
expect(restartNotice()).toBeNull();
|
||||
});
|
||||
|
||||
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
|
||||
|
||||
@@ -7,13 +7,14 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import Switch from "@/ui/Switch";
|
||||
import AuthorityGate from "./AuthorityGate";
|
||||
import FileModeNote from "./FileModeNote";
|
||||
import QueryPanel from "./QueryPanel";
|
||||
import UpstreamForm from "./UpstreamForm";
|
||||
import { styles as config } from "./styles";
|
||||
|
||||
const INTRO = "The pool builds its clients at startup, so an edit here takes effect at the next restart.";
|
||||
const INTRO = "The pool is rebuilt as you save, so an edit here applies to the next query.";
|
||||
|
||||
const styles = stylex.create({
|
||||
url: {
|
||||
@@ -165,13 +166,11 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Switch
|
||||
aria-label={`${u.url} enabled`}
|
||||
checked={u.enabled}
|
||||
disabled={toggle.isPending}
|
||||
isSelected={u.enabled}
|
||||
isDisabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(u)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||
|
||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
||||
function mutationControls(): Element[] {
|
||||
return [
|
||||
...contentArea().querySelectorAll(
|
||||
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
|
||||
'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -99,14 +99,17 @@ test("a failed status is announced by the shell on a page that is not configurat
|
||||
stubApi(DATABASE, {
|
||||
responses: {
|
||||
"GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }),
|
||||
"GET /api/stats?period=24h": {
|
||||
"GET /api/overview?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
queries: 0,
|
||||
blocked: 0,
|
||||
clients: 0,
|
||||
avg_response_time_us: null,
|
||||
bucket_seconds: 1800,
|
||||
totals: { queries: 0, blocked: 0, clients: 0, avg_response_time_us: null },
|
||||
buckets: [],
|
||||
clients: [],
|
||||
other: [],
|
||||
types: [],
|
||||
routes: [],
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
|
||||
test("toggleSource adds a missing id keeping ascending order", () => {
|
||||
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
|
||||
expect(toggleSource([], 5)).toEqual([5]);
|
||||
});
|
||||
|
||||
test("toggleSource removes a present id", () => {
|
||||
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
|
||||
expect(toggleSource([5], 5)).toEqual([]);
|
||||
});
|
||||
|
||||
test("toggleSource twice is a no-op set-wise", () => {
|
||||
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
|
||||
});
|
||||
import { sameSet } from "./sourceSet";
|
||||
|
||||
test("sameSet compares regardless of order", () => {
|
||||
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export function toggleSource(ids: number[], id: number): number[] {
|
||||
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
|
||||
return [...ids, id].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function sameSet(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort((x, y) => x - y);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AuthProvider } from "@/auth/store";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { sample_get_settings } from "@/lib/contractSamples.gen";
|
||||
import type { ConfigStatus, Settings } from "@/lib/types";
|
||||
|
||||
export const CONFIG_PATH = "/etc/nxdns/config.zon";
|
||||
@@ -33,6 +34,13 @@ export const MANAGED_FILE: ConfigStatus = {
|
||||
restart_pending: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The keys `/api/settings` still reports as restart-required, taken from the
|
||||
* committed contract sample so a server-side change to the set fails the tests
|
||||
* that pin it rather than passing against a stale copy.
|
||||
*/
|
||||
export const RESTART_REQUIRED_KEYS: readonly string[] = sample_get_settings.restart_required;
|
||||
|
||||
export function baseSettings(): Settings {
|
||||
return {
|
||||
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
||||
@@ -197,7 +205,7 @@ function defaultResponses(status: ConfigStatus): Record<string, unknown> {
|
||||
"GET /api/upstreams": { upstreams: UPSTREAMS },
|
||||
"GET /api/local-records": { local_records: LOCAL_RECORDS },
|
||||
"GET /api/forward-zones": { forward_zones: FORWARD_ZONES },
|
||||
"GET /api/settings": { settings: baseSettings(), restart_required: ["dns.port", "web.port"] },
|
||||
"GET /api/settings": { settings: baseSettings(), restart_required: RESTART_REQUIRED_KEYS },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { fireEvent, render as renderBare, screen, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import ClientChart, { type ClientChartData } from "./ClientChart";
|
||||
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
|
||||
|
||||
const SINCE = 1_700_000_000;
|
||||
const BUCKET = 1800;
|
||||
|
||||
function clients(named: { client: string; buckets: number[] }[], other: number[]): ClientChartData {
|
||||
return { since: SINCE, bucket_seconds: BUCKET, clients: named, other };
|
||||
}
|
||||
|
||||
const TWO_BUCKETS = clients(
|
||||
[
|
||||
{ client: "192.0.2.30", buckets: [10, 1] },
|
||||
{ client: "192.0.2.31", buckets: [20, 1] },
|
||||
],
|
||||
[5, 1],
|
||||
);
|
||||
|
||||
/**
|
||||
* The chart looks a client's registered name up, so it needs a query client. No
|
||||
* client is registered in these fixtures, which is what leaves the addresses on
|
||||
* screen as the labels.
|
||||
*/
|
||||
function render(data: ClientChartData) {
|
||||
const client = createQueryClient();
|
||||
const tree = (next: ClientChartData) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<ClientChart data={next} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const result = renderBare(tree(data));
|
||||
return { ...result, rerender: (next: ClientChartData) => result.rerender(tree(next)) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ clients: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function overlayRects(container: HTMLElement): SVGRectElement[] {
|
||||
return Array.from(container.querySelectorAll<SVGRectElement>('rect[fill="transparent"]'));
|
||||
}
|
||||
|
||||
test("the data table is the SVG's accessible equivalent", () => {
|
||||
render(TWO_BUCKETS);
|
||||
|
||||
expect(screen.getByRole("img", { name: /client activity over time/i })).toBeTruthy();
|
||||
const table = screen.getByRole("table", { name: "Queries per client per time bucket" });
|
||||
expect(within(table).getAllByRole("row").length).toBe(3);
|
||||
});
|
||||
|
||||
/**
|
||||
* The x-axis is this response's own `since` plus one bucket width per column.
|
||||
* The timeseries endpoint aligns its buckets with these, which is what lets the
|
||||
* two charts stack above one another without either knowing about the other.
|
||||
*/
|
||||
test("the columns are timestamped from this response's own window", () => {
|
||||
render(TWO_BUCKETS);
|
||||
|
||||
const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
|
||||
expect(rows.map((row) => row.textContent)).toEqual([formatTime(SINCE), formatTime(SINCE + BUCKET)]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Every series here is a disjoint part of the whole, so the scale has to come
|
||||
* from the tallest column's own sum. Taking it from the largest single value
|
||||
* instead would run the tallest column off the top of the plot: the axis has to
|
||||
* reach 35 here, not 20.
|
||||
*/
|
||||
test("the value scale covers the tallest column's total, not its largest series", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [20] }], [15]));
|
||||
|
||||
const labels = Array.from(container.querySelectorAll(".visx-axis-left text")).map((label) => label.textContent);
|
||||
expect(labels[labels.length - 1]).toBe("35");
|
||||
});
|
||||
|
||||
test("the series are drawn in the colour of the client's address, and Other in its own", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [5]));
|
||||
|
||||
const fills = Array.from(container.querySelectorAll("rect"))
|
||||
.map((rect) => rect.getAttribute("fill"))
|
||||
.filter((fill) => fill !== "transparent");
|
||||
expect(fills).toEqual([seriesColor(clientKey("192.0.2.30")), seriesColor(OTHER_KEY)]);
|
||||
});
|
||||
|
||||
test("a window with no queries says so instead of drawing an empty grid", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [0, 0] }], [0, 0]));
|
||||
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
test("no bucket at all says the same thing", () => {
|
||||
render(clients([], []));
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("hover text is the tooltip alone, never a bare SVG title", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
expect(container.querySelectorAll("title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("each bucket's hit target spans the full plot height", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
const rects = overlayRects(container);
|
||||
expect(rects).toHaveLength(2);
|
||||
for (const rect of rects) {
|
||||
expect(rect.getAttribute("y")).toBe("8");
|
||||
expect(rect.getAttribute("height")).toBe("210");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A band scale spends a gap after the last column as well as between them, so a
|
||||
* full-step hit target on the last bucket would reach into the right margin.
|
||||
*/
|
||||
test("the last hit target stops at the plot's right edge", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
const last = overlayRects(container).at(-1) as SVGRectElement;
|
||||
const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
|
||||
// 640 fallback width, less the 44px left and 8px right margins.
|
||||
expect(right).toBeCloseTo(44 + 588, 6);
|
||||
});
|
||||
|
||||
/**
|
||||
* The window refreshes every half minute under an open tooltip. The tooltip
|
||||
* holds a bucket index and reads the numbers out of the render it is drawing, so
|
||||
* a refresh that keeps the same buckets updates it rather than leaving last
|
||||
* minute's counts on screen.
|
||||
*/
|
||||
test("a refresh in the same window retells the hovered bucket with the new counts", () => {
|
||||
const { container, rerender } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(
|
||||
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
|
||||
).toEqual(["35", "10", "20", "5"]);
|
||||
|
||||
rerender(
|
||||
clients(
|
||||
[
|
||||
{ client: "192.0.2.30", buckets: [11, 1] },
|
||||
{ client: "192.0.2.31", buckets: [22, 1] },
|
||||
],
|
||||
[6, 1],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
|
||||
).toEqual(["39", "11", "22", "6"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A rolling window is the case a stored copy gets wrong: the bucket the pointer
|
||||
* was over is gone, so index 0 now names a different span.
|
||||
*/
|
||||
test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
|
||||
const { container, rerender } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
rerender({ ...TWO_BUCKETS, since: SINCE + BUCKET });
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
});
|
||||
|
||||
/** The placement the hand-rolled tooltip had, restored over visx's 10px defaults. */
|
||||
test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
|
||||
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
expect(tooltip.style.transform).toBe("translate(200px, 8px)");
|
||||
});
|
||||
|
||||
/**
|
||||
* `withBoundingRects` measures its node once, on mount, so the buckets must not
|
||||
* share one. jsdom reports every rect as zero, so the mount is what this can
|
||||
* observe, not the measurement itself.
|
||||
*/
|
||||
test("each bucket gets its own tooltip mount, so each is measured for itself", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
const rects = overlayRects(container);
|
||||
|
||||
fireEvent.mouseOver(rects[0]);
|
||||
const first = container.querySelector(".visx-tooltip");
|
||||
fireEvent.mouseOver(rects[1]);
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(container.querySelector(".visx-tooltip")).not.toBe(first);
|
||||
});
|
||||
|
||||
test("pointing at a bucket names its total and every series, and dims the rest", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
|
||||
expect(Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent)).toEqual([
|
||||
"Queries",
|
||||
"192.0.2.30",
|
||||
"192.0.2.31",
|
||||
"Other",
|
||||
]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["35", "10", "20", "5"]);
|
||||
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
|
||||
expect(swatches[0]).toContain(seriesColor(clientKey("192.0.2.30")));
|
||||
expect(swatches[2]).toContain(seriesColor(OTHER_KEY));
|
||||
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
|
||||
});
|
||||
|
||||
test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* "Other" is everything outside the top eight clients. In a window where it
|
||||
* counted nothing there is no eighth client to aggregate, and the entry would
|
||||
* appear in the legend, the stack, the tooltip and the table saying only that it
|
||||
* is empty. The named clients stay at zero: a client that went quiet is a fact.
|
||||
*/
|
||||
test("a window where Other counted nothing drops it from every surface", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 0]));
|
||||
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual(["192.0.2.30"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "192.0.2.30"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
|
||||
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "192.0.2.30"]);
|
||||
});
|
||||
|
||||
test("one query outside the named clients is enough to keep Other", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 1]));
|
||||
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
|
||||
"192.0.2.30",
|
||||
"Other",
|
||||
]);
|
||||
});
|
||||
@@ -2,56 +2,42 @@
|
||||
* 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.
|
||||
* The x-axis is derived from this response's own `since` and `bucket_seconds`,
|
||||
* which the API aligns with the timeseries endpoint's buckets, 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 { Group } from "@visx/group";
|
||||
import { BarStack } from "@visx/shape";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { StatsClients } from "@/lib/types";
|
||||
import type { OverviewClientSeries } 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 {
|
||||
BucketOverlay,
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartRoot,
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
StackSegment,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
slotCenter,
|
||||
useActiveIndex,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
valueTicks,
|
||||
type TooltipContent,
|
||||
} from "./chartKit";
|
||||
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",
|
||||
@@ -80,173 +66,154 @@ const styles = stylex.create({
|
||||
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.
|
||||
* "Other" last, so it sits at the top of every column rather than under a client,
|
||||
* and dropped entirely when it counted nothing across the window: an aggregation
|
||||
* bucket that aggregated nothing is a legend entry, a stack key, a tooltip row
|
||||
* and a table column all saying zero. The named clients stay at zero, because a
|
||||
* client that went quiet is something the reader wants to see.
|
||||
*/
|
||||
function seriesOf(data: StatsClients, names: ClientNames): Series[] {
|
||||
function seriesOf(data: ClientChartData, 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 },
|
||||
];
|
||||
if (data.other.every((count) => count === 0)) return named;
|
||||
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
|
||||
}
|
||||
|
||||
export default function ClientChart({ data }: { data: StatsClients }) {
|
||||
const [containerRef, measuredWidth] = useContainerWidth();
|
||||
/**
|
||||
* The slice of the Overview body this chart draws. Declared here rather than
|
||||
* taken whole, so what the chart reads is stated where it is read.
|
||||
*/
|
||||
export interface ClientChartData {
|
||||
since: number;
|
||||
bucket_seconds: number;
|
||||
clients: OverviewClientSeries[];
|
||||
other: number[];
|
||||
}
|
||||
|
||||
/** One column: the timestamp plus one entry per series, keyed by the series key. */
|
||||
type Column = { ts: number } & Record<string, number>;
|
||||
|
||||
export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
const [containerRef, width] = useMeasuredWidth();
|
||||
// A hover survives a re-render only while it still names the same bucket at
|
||||
// the same place: a poll that rolls the window, or a resize, retires it.
|
||||
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.other.length}:${width}`);
|
||||
const names = useClientNames();
|
||||
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
|
||||
|
||||
const series = seriesOf(data, names);
|
||||
const bucketCount = data.other.length;
|
||||
const columns: Column[] = Array.from({ length: bucketCount }, (_, i) => {
|
||||
const column: Column = { ts: data.since + i * data.bucket_seconds };
|
||||
for (const one of series) column[one.key] = one.buckets[i] ?? 0;
|
||||
return column;
|
||||
});
|
||||
|
||||
if (bucketCount === 0) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
if (bucketCount === 0 || columns.every((column) => series.every((one) => column[one.key] === 0))) {
|
||||
return <EmptyChart containerRef={containerRef} />;
|
||||
}
|
||||
|
||||
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 timestamps = columns.map((column) => column.ts);
|
||||
const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0));
|
||||
const plot = plotArea(width);
|
||||
const xScale = bandScale(timestamps, plot);
|
||||
// Every series here is a disjoint part of the whole rather than a highlighted
|
||||
// subset of a separately reported total, so the tallest column's own sum is
|
||||
// the scale.
|
||||
const yScale = valueScale(Math.max(...totals), [plot.bottom, plot.y]);
|
||||
const yTicks = valueTicks(yScale);
|
||||
const colorOf = new Map(series.map((one) => [one.key, one.color]));
|
||||
|
||||
function tooltipOf(index: number): TooltipContent {
|
||||
return {
|
||||
title: formatTime(columns[index].ts),
|
||||
rows: [
|
||||
{ key: "queries", label: "Queries", value: String(totals[index]) },
|
||||
...series.map((one) => ({
|
||||
key: one.key,
|
||||
label: one.label,
|
||||
color: one.color,
|
||||
value: String(columns[index][one.key]),
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const layout = layoutStacked(columns, width, CHART_HEIGHT);
|
||||
const baseline = layout.plot.y + layout.plot.height;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.root)}>
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<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}`}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{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)}
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={xScale}
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
<BarStack<Column, string>
|
||||
data={columns}
|
||||
keys={series.map((one) => one.key)}
|
||||
x={(column) => column.ts}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
color={(key) => colorOf.get(key) ?? seriesColor(key)}
|
||||
>
|
||||
{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)}
|
||||
{(stacks) =>
|
||||
columns.map((column, index) => (
|
||||
<Group
|
||||
key={column.ts}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
>
|
||||
{stacks.map((stack) => {
|
||||
const bar = stack.bars[index];
|
||||
return (
|
||||
<StackSegment
|
||||
key={stack.key}
|
||||
x={bar.x}
|
||||
y={bar.y}
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
fill={bar.color}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(hovered.index)}
|
||||
left={slotCenter(xScale, timestamps[hovered.index], plot)}
|
||||
/>
|
||||
)}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{series.map((one) => (
|
||||
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
|
||||
<li key={one.key} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
|
||||
{one.label}
|
||||
</li>
|
||||
@@ -269,14 +236,14 @@ export default function ClientChart({ data }: { data: StatsClients }) {
|
||||
{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>
|
||||
{series.map((one) => (
|
||||
<td key={one.key}>{column[one.key]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</ChartRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import Donut, { type DonutSlice } from "./Donut";
|
||||
|
||||
function slice(key: string, value: number, color = "#000000"): DonutSlice {
|
||||
return { key, label: key.toUpperCase(), value, color };
|
||||
}
|
||||
|
||||
function ring(container: HTMLElement): SVGPathElement[] {
|
||||
return Array.from(container.querySelectorAll("svg path"));
|
||||
}
|
||||
|
||||
/** The x radius of every `A` command in a path, in the order they are drawn. */
|
||||
function arcRadii(d: string): number[] {
|
||||
return Array.from(d.matchAll(/A(-?[\d.]+),/g)).map((match) => Number(match[1]));
|
||||
}
|
||||
|
||||
function draw(slices: DonutSlice[]) {
|
||||
return render(<Donut slices={slices} caption="Queries by DNS type" unit="Queries" />);
|
||||
}
|
||||
|
||||
test("a breakdown of nothing says so rather than dividing by zero", () => {
|
||||
const { container } = draw([slice("a", 0), slice("b", 0)]);
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
test("an empty breakdown says the same thing", () => {
|
||||
draw([]);
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
});
|
||||
|
||||
/** A legend entry reading 0% is noise, and a zero slice has no arc to draw. */
|
||||
test("zero-valued entries are dropped from the ring, the legend and the table", () => {
|
||||
const { container } = draw([slice("a", 3), slice("z", 0), slice("b", 1)]);
|
||||
|
||||
expect(ring(container)).toHaveLength(2);
|
||||
expect(screen.queryByText("Z")).toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("rowheader")
|
||||
.map((row) => row.textContent),
|
||||
).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The caller has already ranked the slices, so the ring must not re-sort them:
|
||||
* the first arc opens at twelve o'clock and each one starts where the last
|
||||
* ended, running clockwise.
|
||||
*
|
||||
* The fixture runs 1 then 3 on purpose. d3's default pie sort is by value
|
||||
* descending, so a fixture that happened to be given in descending order would
|
||||
* pass with the sort left on and prove nothing.
|
||||
*/
|
||||
test("the ring starts at twelve o'clock and runs clockwise in the order given", () => {
|
||||
const { container } = draw([slice("a", 1, "#112233"), slice("b", 3, "#445566")]);
|
||||
|
||||
const paths = ring(container);
|
||||
// The small slice is drawn first because it was given first.
|
||||
expect(paths[0].getAttribute("fill")).toBe("#112233");
|
||||
expect(paths[0].getAttribute("d")?.startsWith("M0,-90")).toBe(true);
|
||||
// A quarter turn clockwise from the top is three o'clock, where the second
|
||||
// slice picks up.
|
||||
expect(paths[1].getAttribute("fill")).toBe("#445566");
|
||||
expect(paths[1].getAttribute("d")?.startsWith("M90,0")).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* A whole-circle arc from a point back to itself draws nothing at all, so the
|
||||
* one-entry case has to come out as a closed ring: two half arcs out and two
|
||||
* back.
|
||||
*/
|
||||
test("a single entry is a closed ring, not a zero-length arc", () => {
|
||||
const { container } = draw([slice("only", 7)]);
|
||||
|
||||
const paths = ring(container);
|
||||
expect(paths).toHaveLength(1);
|
||||
const d = paths[0].getAttribute("d") ?? "";
|
||||
expect(d.match(/A/g)).toHaveLength(4);
|
||||
// Two half arcs out at the outer radius and two back at the inner one: a
|
||||
// 180px ring 36px thick.
|
||||
expect(arcRadii(d)).toEqual([90, 90, 54, 54]);
|
||||
});
|
||||
|
||||
test("shares are of the drawn total, in the legend and in the hidden table alike", () => {
|
||||
draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
expect(screen.getAllByText("75.0%")).toHaveLength(2);
|
||||
expect(screen.getAllByText("25.0%")).toHaveLength(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* The colour is the caller's, never recomputed from the key here: the page owns
|
||||
* the identity-to-colour mapping and two donuts of one page must agree with it.
|
||||
*/
|
||||
test("each arc is filled with the colour its slice carries", () => {
|
||||
const { container } = draw([slice("a", 3, "#112233"), slice("b", 1, "#445566")]);
|
||||
|
||||
expect(ring(container).map((path) => path.getAttribute("fill"))).toEqual(["#112233", "#445566"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
test("every arc is outlined in the panel's own surface colour", () => {
|
||||
const { container } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
for (const path of ring(container)) {
|
||||
expect(path.getAttribute("stroke-width")).toBe("1");
|
||||
expect(path.getAttribute("stroke")).toMatch(/^var\(--/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the ring is decoration; the legend and the hidden table are the accessible surface", () => {
|
||||
const { container } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(svg?.getAttribute("focusable")).toBe("false");
|
||||
expect(screen.getByRole("table", { name: "Queries by DNS type" })).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* The ring gets the bar charts' hover treatment: the shared tooltip names the
|
||||
* slice and repeats what the legend says about it, and the slices it is not
|
||||
* naming dim out of the way. The ring stays `aria-hidden` — the tooltip is a
|
||||
* pointer affordance, and the legend beside it is still the readable copy.
|
||||
*/
|
||||
test("pointing at a slice names it and dims the rest", () => {
|
||||
const { container } = draw([slice("a", 3, "#112233"), slice("b", 1, "#445566")]);
|
||||
const paths = ring(container);
|
||||
|
||||
fireEvent.mouseOver(paths[0]);
|
||||
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(tooltip.previousElementSibling?.textContent).toBe("A");
|
||||
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual(["Queries", "Share"]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
|
||||
// The same share the legend and the hidden table already print for this slice.
|
||||
expect(screen.getAllByText("75.0%")).toHaveLength(3);
|
||||
|
||||
expect(paths.map((path) => path.getAttribute("opacity"))).toEqual(["1", "0.55"]);
|
||||
expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
|
||||
|
||||
// The tooltip points at the middle of the arc, which the component computes
|
||||
// from the slice values rather than from the drawn path. Slice A is three
|
||||
// quarters of the ring, so its midpoint is at 135 degrees, on a circle of
|
||||
// radius 72 — (140.9, 140.9) from the ring's top-left corner, plus the 8px
|
||||
// the tooltip stands off by. Nothing else here would catch that arithmetic
|
||||
// drifting away from the ring the Pie actually draws.
|
||||
const tooltipBox = container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
expect(tooltipBox.style.transform).toBe("translate(149px, 149px)");
|
||||
});
|
||||
|
||||
test("leaving the ring takes the tooltip and the dimming with it", () => {
|
||||
const { container } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
fireEvent.mouseOver(ring(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
expect(ring(container).map((path) => path.getAttribute("opacity"))).toEqual(["1", "1"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The same slice can change width between refreshes, so a mount measured at the
|
||||
* old width would be placed at the wrong one. This is the donut's half of the
|
||||
* remount the bar charts do.
|
||||
*/
|
||||
test("a refresh keeps the hovered slice current and remeasures it", () => {
|
||||
const { container, rerender } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
fireEvent.mouseOver(ring(container)[0]);
|
||||
const first = container.querySelector(".visx-tooltip");
|
||||
expect(Array.from(first?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
|
||||
|
||||
rerender(<Donut slices={[slice("a", 3000), slice("b", 1000)]} caption="Queries by DNS type" unit="Queries" />);
|
||||
|
||||
const second = container.querySelector(".visx-tooltip");
|
||||
expect(Array.from(second?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual([
|
||||
"3,000",
|
||||
"75.0%",
|
||||
]);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
/** A slice that is gone cannot be described, so the hover goes with it. */
|
||||
test("a refresh that changes the slices takes the tooltip down", () => {
|
||||
const { container, rerender } = draw([slice("a", 3), slice("b", 1)]);
|
||||
|
||||
fireEvent.mouseOver(ring(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
rerender(<Donut slices={[slice("c", 3), slice("b", 1)]} caption="Queries by DNS type" unit="Queries" />);
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
});
|
||||
@@ -13,12 +13,26 @@
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Group } from "@visx/group";
|
||||
import { Pie } from "@visx/shape";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { layoutDonut, type DonutSlice } from "./donutLayout";
|
||||
import { ChartTooltip, useActiveIndex, type TooltipContent } from "./chartKit";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const SIZE = 180;
|
||||
const THICKNESS = 36;
|
||||
const OUTER_RADIUS = SIZE / 2;
|
||||
const INNER_RADIUS = OUTER_RADIUS - THICKNESS;
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
@@ -54,8 +68,12 @@ const styles = stylex.create({
|
||||
justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" },
|
||||
gap: "1.25rem",
|
||||
},
|
||||
/** The tooltip is placed against the ring's own box, so slice coordinates can
|
||||
* be used unchanged rather than measured against the whole panel. */
|
||||
ring: {
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
lineHeight: 0,
|
||||
},
|
||||
/**
|
||||
* Capped and left-anchored. Without the cap the row justifies across whatever
|
||||
@@ -115,6 +133,21 @@ function sharePercent(share: number): string {
|
||||
return `${(share * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a slice's tooltip points: the middle of its arc, in the ring box's own
|
||||
* coordinates. This restates the `Pie` configuration below — clockwise from
|
||||
* twelve o'clock, caller order, no pad angle — and the two must agree.
|
||||
*/
|
||||
function sliceAnchor(drawn: DonutSlice[], index: number, total: number): { left: number; top: number } {
|
||||
const before = drawn.slice(0, index).reduce((sum, slice) => sum + slice.value, 0);
|
||||
const middle = (2 * Math.PI * (before + drawn[index].value / 2)) / total;
|
||||
const radius = (OUTER_RADIUS + INNER_RADIUS) / 2;
|
||||
return {
|
||||
left: OUTER_RADIUS + Math.sin(middle) * radius,
|
||||
top: OUTER_RADIUS - Math.cos(middle) * radius,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Donut({
|
||||
slices,
|
||||
caption,
|
||||
@@ -126,56 +159,108 @@ export default function Donut({
|
||||
/** The column header for the counted thing, e.g. "Queries". */
|
||||
unit: string;
|
||||
}) {
|
||||
const layout = layoutDonut(slices, SIZE, THICKNESS);
|
||||
// Zero-valued entries have no arc to draw and a legend entry reading 0 is
|
||||
// noise, so they are dropped before anything is measured or drawn.
|
||||
const drawn = slices.filter((slice) => slice.value > 0);
|
||||
const total = drawn.reduce((sum, slice) => sum + slice.value, 0);
|
||||
// A hover names a slice by position, so it survives only while the ring is
|
||||
// still made of the same slices in the same order.
|
||||
const hovered = useActiveIndex(drawn.map((slice) => slice.key).join(","));
|
||||
|
||||
if (layout.total === 0) {
|
||||
function tooltipOf(index: number): TooltipContent {
|
||||
const slice = drawn[index];
|
||||
return {
|
||||
title: slice.secondary === undefined ? slice.label : `${slice.label} (${slice.secondary})`,
|
||||
rows: [
|
||||
{ key: "value", label: unit, color: slice.color, value: numberFormat.format(slice.value) },
|
||||
{ key: "share", label: "Share", value: sharePercent(slice.value / total) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return <div {...stylex.props(styles.empty)}>No queries in this period.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.body)}>
|
||||
<div {...stylex.props(styles.ring)}>
|
||||
{/* The ring stays out of the accessibility tree even though it is now
|
||||
a pointer target: the tooltip repeats what the legend beside it
|
||||
already says in text, so nothing here is the only copy. */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
width={SIZE}
|
||||
height={SIZE}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
{...stylex.props(styles.ring)}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{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.
|
||||
{/* Arc paths are generated around the origin, and `Pie`'s own
|
||||
`top`/`left` group is skipped when it is given a render prop, so
|
||||
the ring is centred here instead. */}
|
||||
<Group top={OUTER_RADIUS} left={OUTER_RADIUS}>
|
||||
<Pie
|
||||
data={drawn}
|
||||
pieValue={(slice) => slice.value}
|
||||
// The caller has already ranked the slices, so d3's own sort is off
|
||||
// in both of its forms and the ring runs clockwise from twelve
|
||||
// o'clock in the order given. @visx/shape 4.0.0 already disables
|
||||
// it by default — but that default exists because d3-shape v3
|
||||
// changed sortValues to descending under it, so saying it here is
|
||||
// what keeps a future bump from silently re-ranking the ring.
|
||||
pieSort={null}
|
||||
pieSortValues={null}
|
||||
outerRadius={OUTER_RADIUS}
|
||||
innerRadius={INNER_RADIUS}
|
||||
>
|
||||
{({ arcs, path }) =>
|
||||
arcs.map((arc, index) => (
|
||||
// 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"
|
||||
key={arc.data.key}
|
||||
d={path(arc) ?? ""}
|
||||
fill={arc.data.color}
|
||||
stroke={colors.surfaceRaised}
|
||||
strokeWidth={1}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
onMouseEnter={() => hovered.show(index)}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
}
|
||||
</Pie>
|
||||
</Group>
|
||||
</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))}
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(hovered.index)}
|
||||
{...sliceAnchor(drawn, hovered.index, total)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{drawn.map((slice) => (
|
||||
<li key={slice.key} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(slice.color))} />
|
||||
<span {...stylex.props(styles.label)}>
|
||||
{arc.slice.label}
|
||||
{arc.slice.secondary !== undefined && (
|
||||
<span {...stylex.props(styles.secondary)}>{arc.slice.secondary}</span>
|
||||
{slice.label}
|
||||
{slice.secondary !== undefined && (
|
||||
<span {...stylex.props(styles.secondary)}>{slice.secondary}</span>
|
||||
)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
||||
{numberFormat.format(arc.slice.value)}
|
||||
{numberFormat.format(slice.value)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.share, shared.tabularNums)}>
|
||||
{sharePercent(slice.value / total)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.share, shared.tabularNums)}>{sharePercent(arc.share)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -190,15 +275,15 @@ export default function Donut({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{layout.arcs.map((arc) => (
|
||||
<tr key={arc.slice.key}>
|
||||
{drawn.map((slice) => (
|
||||
<tr key={slice.key}>
|
||||
<th scope="row">
|
||||
{arc.slice.secondary === undefined
|
||||
? arc.slice.label
|
||||
: `${arc.slice.label} (${arc.slice.secondary})`}
|
||||
{slice.secondary === undefined
|
||||
? slice.label
|
||||
: `${slice.label} (${slice.secondary})`}
|
||||
</th>
|
||||
<td>{arc.slice.value}</td>
|
||||
<td>{sharePercent(arc.share)}</td>
|
||||
<td>{slice.value}</td>
|
||||
<td>{sharePercent(slice.value / total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* The part of Overview that does not wait for anything: the heading, the period
|
||||
* picker, and the pulsing body the page shows while the window is in flight.
|
||||
*
|
||||
* It lives apart from `OverviewPage` so the route's pending component can render
|
||||
* the identical surface while the page chunk loads. Importing the page itself
|
||||
* would pull the charts into the main bundle, and a second hand-written copy of
|
||||
* the frame would drift. Nothing here imports a chart.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { Radio, RadioGroup } from "react-aria-components";
|
||||
import type { Period } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { DEFAULT_PERIOD, PERIODS } from "./period";
|
||||
|
||||
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: {
|
||||
cursor: "pointer",
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.625rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
||||
periodFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** 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,
|
||||
},
|
||||
loading: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
||||
return (
|
||||
<RadioGroup
|
||||
aria-label="Period"
|
||||
orientation="horizontal"
|
||||
value={period}
|
||||
onChange={(next) => onChange(next as Period)}
|
||||
className={() => stylex.props(styles.periodGroup).className ?? ""}
|
||||
>
|
||||
{PERIODS.map((option) => (
|
||||
<Radio
|
||||
key={option}
|
||||
value={option}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.period,
|
||||
isSelected ? styles.periodSelected : styles.periodIdle,
|
||||
isFocusVisible && styles.periodFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewLoading() {
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.loading, shared.pulse)}>
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewFrame({
|
||||
period,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
period: Period;
|
||||
onChange: (period: Period) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div {...stylex.props(styles.page)}>
|
||||
<div {...stylex.props(styles.headingRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Overview</h1>
|
||||
<PeriodPicker period={period} onChange={onChange} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route's pending surface. The picker stays live because it only writes the
|
||||
* search parameter, which the route already re-reads on its own.
|
||||
*/
|
||||
export function OverviewPending() {
|
||||
const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
|
||||
const navigate = useNavigate({ from: "/overview" });
|
||||
return (
|
||||
<OverviewFrame
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
>
|
||||
<OverviewLoading />
|
||||
</OverviewFrame>
|
||||
);
|
||||
}
|
||||
@@ -14,66 +14,34 @@ 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 { clientKey, qtypeKey, seriesColor } from "./seriesColors";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Health, StatsClients, StatsRoutes, StatsTimeseries, StatsTotals, StatsTypes } from "@/lib/types";
|
||||
import type { Health, Overview } 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 = {
|
||||
const OVERVIEW: Overview = {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: UNTIL,
|
||||
bucket_seconds: 1800,
|
||||
totals: { queries: 1000, blocked: 250, clients: 7, avg_response_time_us: 2345 },
|
||||
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 },
|
||||
@@ -83,22 +51,27 @@ const ROUTES: StatsRoutes = {
|
||||
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,
|
||||
/** The same shape an hour wide and empty, so a period change is observable. */
|
||||
const HOUR: Overview = {
|
||||
...OVERVIEW,
|
||||
period: "1h",
|
||||
since: UNTIL - 3600,
|
||||
bucket_seconds: 60,
|
||||
totals: { queries: 12, blocked: 3, clients: 2, avg_response_time_us: 2345 },
|
||||
buckets: [],
|
||||
clients: [],
|
||||
other: [],
|
||||
types: [],
|
||||
routes: [],
|
||||
};
|
||||
|
||||
let healthBody: Health;
|
||||
let failing: Set<string>;
|
||||
let failing: boolean;
|
||||
/** 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>>;
|
||||
/** Held in flight, so a test can look at the page while the request is pending. */
|
||||
let delayed: Promise<void> | null;
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
@@ -110,27 +83,18 @@ function withCoverage<T extends { coverage: typeof COVERAGE }>(body: T): T {
|
||||
|
||||
beforeEach(() => {
|
||||
healthBody = health();
|
||||
failing = new Set();
|
||||
failing = false;
|
||||
registered = [];
|
||||
coverageComplete = true;
|
||||
delayed = new Map();
|
||||
delayed = null;
|
||||
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.startsWith("/api/overview")) {
|
||||
if (failing) return json({ error: "endpoint unavailable" }, 400);
|
||||
if (delayed !== null) await delayed;
|
||||
return json(withCoverage(url.includes("period=1h") ? HOUR : OVERVIEW));
|
||||
}
|
||||
if (url === "/api/clients") {
|
||||
return json({
|
||||
@@ -203,25 +167,39 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
test("the page builds a donut slice's colour from the entry's identity", async () => {
|
||||
// `Donut` renders the colour it is handed and never recomputes one, so the
|
||||
// mapping from identity to hue is the page's job and is pinned here.
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
|
||||
|
||||
const item = within(panel("Query types")).getAllByText("A")[0].closest("li") as HTMLElement;
|
||||
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
|
||||
expect(swatch.getAttribute("style")).toContain(seriesColor(qtypeKey(1)));
|
||||
});
|
||||
|
||||
test("a request in flight leaves the heading and the picker usable behind one loading surface", async () => {
|
||||
// Through the real route, which is the point: the loader starts the request
|
||||
// and awaits it nowhere. If it awaited, the router would hold the whole page —
|
||||
// heading and period picker included — until the response landed.
|
||||
let release = () => {};
|
||||
delayed.set("/api/stats/routes", new Promise<void>((resolve) => (release = resolve)));
|
||||
delayed = 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…");
|
||||
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
||||
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
|
||||
// One loading state for the whole page, not one per panel.
|
||||
const loading = await screen.findByText("Loading…");
|
||||
expect(loading.getAttribute("role")).toBe("status");
|
||||
expect(screen.getAllByText("Loading…")).toHaveLength(1);
|
||||
expect(screen.queryByRole("heading", { name: "Query types" })).toBeNull();
|
||||
|
||||
release();
|
||||
await waitFor(() => expect(within(panel("Upstream servers")).queryByRole("status")).toBeNull());
|
||||
delayed = null;
|
||||
await screen.findByText("1,000");
|
||||
expect(screen.queryByText("Loading…")).toBeNull();
|
||||
});
|
||||
|
||||
test("a registered client is named in the chart, an unregistered one keeps its address", async () => {
|
||||
@@ -236,6 +214,16 @@ test("a registered client is named in the chart, an unregistered one keeps its a
|
||||
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);
|
||||
|
||||
// Nor on hover: a pointer-only tooltip would say what the design just chose
|
||||
// not to, and only to a reader holding a mouse.
|
||||
const legendItem = within(chart)
|
||||
.getAllByText("kitchen-pi")
|
||||
.map((node) => node.closest("li"))
|
||||
.find((node) => node !== null);
|
||||
expect(legendItem).toBeTruthy();
|
||||
expect(legendItem?.getAttribute("title")).toBeNull();
|
||||
expect(within(chart).getByRole("list").querySelectorAll("[title]")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a client named only by reverse DNS is named by it too", async () => {
|
||||
@@ -259,9 +247,10 @@ test("naming a client does not recolour its series", async () => {
|
||||
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.
|
||||
test("the client chart drops Other in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. An aggregation bucket that
|
||||
// aggregated nothing is a legend entry and a table column that say only that
|
||||
// they are empty; the named clients stay, because a quiet client is a fact.
|
||||
renderApp();
|
||||
await screen.findByRole("heading", { name: "Client activity over time" });
|
||||
|
||||
@@ -269,8 +258,8 @@ test("the client chart names Other even in a period where it counted nothing", a
|
||||
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);
|
||||
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2));
|
||||
expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => {
|
||||
@@ -356,14 +345,23 @@ test("an empty window says so in every panel instead of drawing nothing", async
|
||||
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");
|
||||
// One radio group named Period, holding the four periods and exactly one
|
||||
// selection: the segmented picker is a single choice, not four toggles.
|
||||
const picker = within(screen.getByRole("radiogroup", { name: "Period" }));
|
||||
expect(picker.getAllByRole("radio").map((radio) => radio.getAttribute("value"))).toEqual([
|
||||
"1h",
|
||||
"24h",
|
||||
"7d",
|
||||
"30d",
|
||||
]);
|
||||
expect(picker.getByRole("radio", { name: "1h", checked: true })).toBeTruthy();
|
||||
expect(picker.getByRole("radio", { name: "24h", checked: false })).toBeTruthy();
|
||||
});
|
||||
|
||||
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(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
|
||||
expect(router.state.location.search).toEqual({});
|
||||
});
|
||||
|
||||
@@ -371,7 +369,7 @@ 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" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "1h" }));
|
||||
|
||||
await screen.findByText("12");
|
||||
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
|
||||
@@ -379,17 +377,24 @@ test("the picker rescopes every panel and writes the period into the url", async
|
||||
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");
|
||||
test("a failed request is one error for the whole page, stated once and retryable", async () => {
|
||||
failing = true;
|
||||
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();
|
||||
await screen.findByText("endpoint unavailable");
|
||||
// One statement of the failure, not one per panel: there is a single request
|
||||
// behind every panel, so a second copy would only repeat this sentence.
|
||||
expect(screen.getAllByText("endpoint unavailable")).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
|
||||
// The heading and the picker survive it, so the reader can rescope or retry.
|
||||
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
|
||||
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
|
||||
failing = false;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await screen.findByText("1,000");
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an incomplete window states its watermark once for the whole page", async () => {
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
* 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.
|
||||
* One request feeds every panel (`overviewWindow.ts`), so the page has one
|
||||
* loading state and one error state rather than six: there is no longer a
|
||||
* partial answer to render, and nothing left for a panel to disagree about.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
@@ -18,16 +18,16 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import CoverageNotice from "@/lib/CoverageNotice";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { qtypeName } from "@/features/provenance/qtype";
|
||||
import type { Period, StatsRoutes, StatsTypes } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import type { Overview, OverviewRouteRow, OverviewTypeRow } from "@/lib/types";
|
||||
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 type { DonutSlice } from "./Donut";
|
||||
import { OverviewFrame, OverviewLoading } from "./OverviewFrame";
|
||||
import { useOverviewWindow, type Panel } from "./overviewWindow";
|
||||
import { DEFAULT_PERIOD, PERIODS } from "./period";
|
||||
import { DEFAULT_PERIOD } from "./period";
|
||||
import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
|
||||
|
||||
/**
|
||||
@@ -48,48 +48,6 @@ const ROUTE_LABELS = {
|
||||
} 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,
|
||||
@@ -110,54 +68,20 @@ const styles = stylex.create({
|
||||
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.
|
||||
* The page's three states. The heading and the period picker stay put through
|
||||
* all three, so the reader can rescope or retry without waiting for anything.
|
||||
*/
|
||||
function PanelBody<T>({ panel, children }: { panel: Panel<T>; children: (data: T) => React.ReactNode }) {
|
||||
function PageBody({ panel, children }: { panel: Panel<Overview>; children: (data: Overview) => 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>
|
||||
);
|
||||
}
|
||||
if (panel.status === "loading") return <OverviewLoading />;
|
||||
return <>{children(panel.data)}</>;
|
||||
}
|
||||
|
||||
function typeSlices(data: StatsTypes): DonutSlice[] {
|
||||
return data.types.map((row) => ({
|
||||
function typeSlices(types: OverviewTypeRow[]): DonutSlice[] {
|
||||
return types.map((row) => ({
|
||||
key: qtypeKey(row.qtype),
|
||||
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
|
||||
value: row.count,
|
||||
@@ -171,8 +95,8 @@ function typeSlices(data: StatsTypes): DonutSlice[] {
|
||||
* 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) => {
|
||||
function routeSlices(routes: OverviewRouteRow[]): DonutSlice[] {
|
||||
return routes.map((row) => {
|
||||
const named = row.route === "upstream" || row.route === "forward_zone";
|
||||
return {
|
||||
key: routeKey(row.route, row.source),
|
||||
@@ -190,33 +114,31 @@ export default function OverviewPage() {
|
||||
const overview = useOverviewWindow(period);
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.page)}>
|
||||
<div {...stylex.props(styles.headingRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Overview</h1>
|
||||
<PeriodPicker
|
||||
<OverviewFrame
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<PageBody panel={overview}>
|
||||
{(data) => (
|
||||
<>
|
||||
<StatTiles stats={{ since: data.since, until: data.until, ...data.totals }} />
|
||||
|
||||
<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} />}
|
||||
{/* One notice for the page: every panel came out of this one
|
||||
response, so a second copy would only repeat this sentence. */}
|
||||
<CoverageNotice coverage={data.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>
|
||||
<TimeseriesChart data={data} />
|
||||
</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>
|
||||
<ClientChart data={data} />
|
||||
</section>
|
||||
|
||||
<div {...stylex.props(styles.donutRow)}>
|
||||
@@ -224,25 +146,22 @@ export default function OverviewPage() {
|
||||
<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>
|
||||
<Donut slices={typeSlices(data.types)} caption="Queries by DNS type" unit="Queries" />
|
||||
</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)}
|
||||
slices={routeSlices(data.routes)}
|
||||
caption="Queries by how they were answered"
|
||||
unit="Queries"
|
||||
/>
|
||||
)}
|
||||
</PanelBody>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageBody>
|
||||
</OverviewFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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
|
||||
* The Activity links carry the bounds the **overview 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.
|
||||
*/
|
||||
@@ -13,7 +13,7 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { formatMicros } from "@/lib/format";
|
||||
import type { StatsTotals } from "@/lib/types";
|
||||
import type { OverviewTotals } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
@@ -101,7 +101,13 @@ function Tile({
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatTiles({ stats }: { stats: StatsTotals }) {
|
||||
/** The window's totals with the bounds they were measured over. */
|
||||
export interface StatTilesData extends OverviewTotals {
|
||||
since: number;
|
||||
until: number;
|
||||
}
|
||||
|
||||
export default function StatTiles({ stats }: { stats: StatTilesData }) {
|
||||
const window = {
|
||||
mode: "history" as const,
|
||||
since: stats.since,
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import type { StatsTimeseries } from "@/lib/types";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { Bucket } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import TimeseriesChart from "./TimeseriesChart";
|
||||
import TimeseriesChart, { type TimeseriesData } 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) => ({
|
||||
function timeseries(buckets: Bucket[]): TimeseriesData {
|
||||
return { since: SINCE, bucket_seconds: 1800, buckets };
|
||||
}
|
||||
|
||||
function counting(bucketCount: number): TimeseriesData {
|
||||
return timeseries(
|
||||
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. */
|
||||
@@ -29,8 +29,13 @@ function hiddenElement(container: HTMLElement): Element | null {
|
||||
return container.querySelector(classes.map((name) => `.${name}`).join(""));
|
||||
}
|
||||
|
||||
/** The transparent per-bucket hit targets, in bucket order. */
|
||||
function overlayRects(container: HTMLElement): SVGRectElement[] {
|
||||
return Array.from(container.querySelectorAll<SVGRectElement>('rect[fill="transparent"]'));
|
||||
}
|
||||
|
||||
test("the data table is the SVG's accessible equivalent", () => {
|
||||
render(<TimeseriesChart data={timeseries(3)} />);
|
||||
render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
const table = screen.getByRole("table", { name: "Queries per time bucket" });
|
||||
@@ -44,9 +49,339 @@ test("the data table is the SVG's accessible equivalent", () => {
|
||||
* 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 { container } = render(<TimeseriesChart data={counting(48)} />);
|
||||
|
||||
const hidden = hiddenElement(container);
|
||||
expect(hidden?.tagName).toBe("DIV");
|
||||
expect(hidden?.querySelector("table")).not.toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* "Allowed" is what the reported total leaves over, and the three counts come
|
||||
* from separate columns that a partial write can leave inconsistent. A negative
|
||||
* remainder would draw a segment upside down.
|
||||
*/
|
||||
test("allowed is the remainder of the reported total, clamped at zero", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 8, cached: 5 }])} />,
|
||||
);
|
||||
|
||||
const row = within(screen.getByRole("table")).getAllByRole("row")[1];
|
||||
expect(
|
||||
within(row)
|
||||
.getAllByRole("cell")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["10", "8", "5", "0"]);
|
||||
// Blocked and cached are drawn; the empty "allowed" segment is not.
|
||||
expect(container.querySelectorAll('rect[fill="#3b82f6"]')).toHaveLength(0);
|
||||
|
||||
// The scale comes from the reported total, not from the stack's own sum.
|
||||
// Scaling to the sum would reach 13 here and leave the bar four fifths of the
|
||||
// way up a plot whose own numbers say it is full.
|
||||
const ticks = Array.from(container.querySelectorAll(".visx-axis-left text")).map((tick) => tick.textContent);
|
||||
expect(ticks[ticks.length - 1]).toBe("10");
|
||||
});
|
||||
|
||||
test("a window with no queries says so instead of drawing an empty grid", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 0, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 0, blocked: 0, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
test("no bucket at all says the same thing", () => {
|
||||
render(<TimeseriesChart data={timeseries([])} />);
|
||||
expect(screen.getByText("No queries in this period.")).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* The three category colours are fixed constants of this chart rather than
|
||||
* anything derived from `seriesColors`, which would paint "other" grey.
|
||||
*/
|
||||
test("the segments are drawn in this chart's own category colours", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 100, blocked: 40, cached: 10 }])} />,
|
||||
);
|
||||
|
||||
const fills = Array.from(container.querySelectorAll("rect"))
|
||||
.map((rect) => rect.getAttribute("fill"))
|
||||
.filter((fill) => fill !== "transparent");
|
||||
expect(fills).toEqual(["#ef4444", "#059669", "#3b82f6"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The graphic carries no `<title>`: it duplicated the tooltip, the browser
|
||||
* showed it after its own delay and out of the app's styling, and the hidden
|
||||
* table is already the accessible equivalent.
|
||||
*/
|
||||
test("hover text is the tooltip alone, never a bare SVG title", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
expect(container.querySelectorAll("title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
/** The hit target is the whole column slot, including the space above a short stack. */
|
||||
test("each bucket's hit target spans the full plot height", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
const rects = overlayRects(container);
|
||||
expect(rects).toHaveLength(3);
|
||||
for (const rect of rects) {
|
||||
expect(rect.getAttribute("y")).toBe("8");
|
||||
expect(rect.getAttribute("height")).toBe("210");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A band scale spends a gap after the last column as well as between them, so a
|
||||
* full-step hit target on the last bucket would reach into the right margin and
|
||||
* catch pointers that are past the plot entirely.
|
||||
*/
|
||||
test("the last hit target stops at the plot's right edge", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
const last = overlayRects(container).at(-1) as SVGRectElement;
|
||||
const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
|
||||
// 640 fallback width, less the 44px left and 8px right margins.
|
||||
expect(right).toBeCloseTo(44 + 588, 6);
|
||||
});
|
||||
|
||||
test("pointing at a bucket names its total and every series, and dims the rest", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
|
||||
const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
|
||||
expect(values).toEqual(["100", "40", "10", "50"]);
|
||||
const terms = Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent);
|
||||
expect(terms).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
|
||||
// One swatch per series, in the colour the segment is drawn in.
|
||||
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
|
||||
expect(swatches[0]).toContain("#ef4444");
|
||||
expect(swatches[1]).toContain("#059669");
|
||||
expect(swatches[2]).toContain("#3b82f6");
|
||||
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The tooltip sits 8px down from the chart's top edge and 8px to the side of the
|
||||
* slot it names — the placement the hand-rolled tooltip had, restored over
|
||||
* visx's own 10px defaults.
|
||||
*/
|
||||
test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(2)} />);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
|
||||
// The first slot spans 44 to 44 + step, so its centre is 191.5 and the
|
||||
// tooltip sits 8px right of it. visx rounds the placement to whole pixels.
|
||||
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
expect(tooltip.style.transform).toBe("translate(200px, 8px)");
|
||||
});
|
||||
|
||||
/**
|
||||
* `withBoundingRects` measures its node once, on mount. Sharing one mount across
|
||||
* buckets would place a wide bucket's tooltip with a narrow bucket's measured
|
||||
* width, which flips or clips it at the right-hand edge of the plot. Remounting
|
||||
* per bucket is what forces a fresh measurement; jsdom reports every rect as
|
||||
* zero, so the mount is what this can observe, not the measurement itself.
|
||||
*/
|
||||
test("each bucket gets its own tooltip mount, so each is measured for itself", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(2)} />);
|
||||
const rects = overlayRects(container);
|
||||
|
||||
fireEvent.mouseOver(rects[0]);
|
||||
const first = container.querySelector(".visx-tooltip");
|
||||
fireEvent.mouseOver(rects[1]);
|
||||
const second = container.querySelector(".visx-tooltip");
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
/**
|
||||
* The window refreshes every half minute under an open tooltip. The tooltip
|
||||
* holds a bucket index and reads the numbers out of the render it is drawing, so
|
||||
* a refresh that keeps the same buckets updates it rather than leaving last
|
||||
* minute's counts on screen.
|
||||
*/
|
||||
test("a refresh in the same window retells the hovered bucket with the new counts", () => {
|
||||
const before = timeseries([
|
||||
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
]);
|
||||
const { container, rerender } = render(<TimeseriesChart data={before} />);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(
|
||||
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
|
||||
).toEqual(["100", "40", "10", "50"]);
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 120, blocked: 60, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
const values = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map(
|
||||
(dd) => dd.textContent,
|
||||
);
|
||||
expect(values).toEqual(["120", "60", "10", "50"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A rolling window is the case a stored copy gets wrong: the bucket the pointer
|
||||
* was over is gone, so index 0 now names a different span. The tooltip goes away
|
||||
* rather than describing a bucket that is no longer drawn, and nothing stays
|
||||
* dimmed behind it. Rolling back to the earlier window must not bring it back
|
||||
* either: the selection is deleted when the window moves, not held aside.
|
||||
*/
|
||||
test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
|
||||
const { container, rerender } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
data={{
|
||||
...timeseries([
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
{ ts: SINCE + 3600, queries: 70, blocked: 7, cached: 7 },
|
||||
]),
|
||||
since: SINCE + 1800,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* The same bucket can change width between refreshes — a count crossing a digit
|
||||
* boundary, a client name arriving — and a mount measured at the old width is
|
||||
* placed at the wrong one. A content change therefore remounts the tooltip, the
|
||||
* same way moving between buckets does.
|
||||
*/
|
||||
test("a bucket whose numbers change is remounted, so it is measured again", () => {
|
||||
const { container, rerender } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 9, blocked: 4, cached: 1 }])} />,
|
||||
);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const first = container.querySelector(".visx-tooltip");
|
||||
|
||||
rerender(<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 1000, blocked: 4, cached: 1 }])} />);
|
||||
const second = container.querySelector(".visx-tooltip");
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).not.toBeNull();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
|
||||
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(0);
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* The third series is every query neither blocked nor served from cache. It was
|
||||
* called "Other", which named the arithmetic rather than the thing.
|
||||
*/
|
||||
test("the remainder series is called Allowed everywhere it surfaces", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 2, cached: 3 }])} />,
|
||||
);
|
||||
|
||||
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
|
||||
expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "Queries", "Blocked", "Cached", "Allowed"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
|
||||
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
|
||||
expect(screen.queryByText("Other")).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* A window where everything was blocked or served from cache has no allowed
|
||||
* queries, and that is worth reading rather than hiding: an absent series would
|
||||
* say the same thing as a series nobody looked at. The three categories are all
|
||||
* real answers a query can get, so none of them is dropped for counting zero.
|
||||
* The client chart's "Other" is dropped at zero, but that one aggregates clients
|
||||
* beyond the top eight rather than naming a kind of answer.
|
||||
*/
|
||||
test("a window with nothing allowed keeps the series at zero", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 4, blocked: 4, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 6, blocked: 6, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
|
||||
expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual([
|
||||
"Queries",
|
||||
"Blocked",
|
||||
"Cached",
|
||||
"Allowed",
|
||||
]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["4", "4", "0", "0"]);
|
||||
});
|
||||
|
||||
@@ -1,107 +1,49 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Group } from "@visx/group";
|
||||
import { BarStack } from "@visx/shape";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { StatsTimeseries } from "@/lib/types";
|
||||
import type { Bucket } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
|
||||
import {
|
||||
BucketOverlay,
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartRoot,
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
StackSegment,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
slotCenter,
|
||||
useActiveIndex,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
valueTicks,
|
||||
type TooltipContent,
|
||||
} from "./chartKit";
|
||||
|
||||
// Series colors validated for CVD separation and 3:1 surface contrast in both
|
||||
// modes (Tailwind red-500 / blue-500 / emerald-600; same hex light and dark).
|
||||
// The third key stays `other` — it is the colour key and the response field, and
|
||||
// renaming it would repaint the series. Only what the reader sees is "Allowed".
|
||||
const SERIES = [
|
||||
{ key: "blocked", label: "Blocked", color: "#ef4444" },
|
||||
{ key: "cached", label: "Cached", color: "#059669" },
|
||||
{ key: "other", label: "Other", color: "#3b82f6" },
|
||||
{ key: "other", label: "Allowed", color: "#3b82f6" },
|
||||
] as const;
|
||||
|
||||
const CHART_HEIGHT = 240;
|
||||
const FALLBACK_WIDTH = 640;
|
||||
type Series = (typeof SERIES)[number];
|
||||
type SeriesKey = Series["key"];
|
||||
|
||||
const SERIES_COLOR: Record<SeriesKey, string> = {
|
||||
blocked: SERIES[0].color,
|
||||
cached: SERIES[1].color,
|
||||
other: SERIES[2].color,
|
||||
};
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
chartRoot: {
|
||||
position: "relative",
|
||||
},
|
||||
tooltip: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
top: "0.5rem",
|
||||
zIndex: 10,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
/** Dynamic: the tooltip flips to whichever side of the bar has room. */
|
||||
tooltipLeft: (left: number) => ({ left, right: null }),
|
||||
tooltipRight: (right: number) => ({ left: null, right }),
|
||||
tooltipTitle: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
tooltipList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.125rem",
|
||||
marginTop: "0.25rem",
|
||||
},
|
||||
tooltipRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
},
|
||||
tooltipTerm: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
swatchSmall: {
|
||||
width: "0.5rem",
|
||||
height: "0.5rem",
|
||||
},
|
||||
swatchLarge: {
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
},
|
||||
gridLine: {
|
||||
stroke: colors.border,
|
||||
},
|
||||
axisLine: {
|
||||
stroke: colors.borderStrong,
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
legend: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
@@ -117,177 +59,141 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG 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];
|
||||
interface Column {
|
||||
ts: number;
|
||||
queries: number;
|
||||
blocked: number;
|
||||
cached: number;
|
||||
/** queries - blocked - cached, clamped at 0. */
|
||||
other: number;
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
function formatTick(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
function columnsOf(buckets: Bucket[]): Column[] {
|
||||
return buckets.map((bucket) => ({
|
||||
ts: bucket.ts,
|
||||
queries: bucket.queries,
|
||||
blocked: bucket.blocked,
|
||||
cached: bucket.cached,
|
||||
other: Math.max(0, bucket.queries - bucket.blocked - bucket.cached),
|
||||
}));
|
||||
}
|
||||
|
||||
function barSummary(bar: BarLayout): string {
|
||||
return `${formatTime(bar.bucket.ts)}: ${bar.bucket.queries} queries, ${bar.bucket.blocked} blocked, ${bar.bucket.cached} cached`;
|
||||
function tooltipOf(column: Column): TooltipContent {
|
||||
return {
|
||||
title: formatTime(column.ts),
|
||||
rows: [
|
||||
{ key: "queries", label: "Queries", value: String(column.queries) },
|
||||
...SERIES.map((series) => ({
|
||||
key: series.key,
|
||||
label: series.label,
|
||||
color: series.color,
|
||||
value: String(column[series.key]),
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
|
||||
const centerX = bar.slot.x + bar.slot.width / 2;
|
||||
const leftHalf = centerX < chartWidth / 2;
|
||||
const side = leftHalf
|
||||
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
|
||||
: styles.tooltipRight(chartWidth - centerX + 8);
|
||||
return (
|
||||
<div {...stylex.props(styles.tooltip, side)}>
|
||||
<div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
|
||||
<dl {...stylex.props(styles.tooltipList)}>
|
||||
<div {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
|
||||
</div>
|
||||
{SERIES.map((series) => (
|
||||
<div key={series.key} {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
|
||||
/>
|
||||
{series.label}
|
||||
</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>
|
||||
{series.key === "other" ? bar.other : bar.bucket[series.key]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
/**
|
||||
* The slice of the Overview body this chart draws. Declared here rather than
|
||||
* taken whole, so what the chart reads is stated where it is read.
|
||||
*/
|
||||
export interface TimeseriesData {
|
||||
since: number;
|
||||
bucket_seconds: number;
|
||||
buckets: Bucket[];
|
||||
}
|
||||
|
||||
export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
||||
const [containerRef, measuredWidth] = useContainerWidth();
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
|
||||
export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
const [containerRef, width] = useMeasuredWidth();
|
||||
// A hover survives a re-render only while it still names the same bucket at
|
||||
// the same place: a poll that rolls the window, or a resize, retires it.
|
||||
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.buckets.length}:${width}`);
|
||||
|
||||
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
if (data.buckets.length === 0 || data.buckets.every((bucket) => bucket.queries === 0)) {
|
||||
return <EmptyChart containerRef={containerRef} />;
|
||||
}
|
||||
|
||||
const layout = layoutTimeseries(data.buckets, width, CHART_HEIGHT);
|
||||
const baseline = layout.plot.y + layout.plot.height;
|
||||
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
|
||||
const columns = columnsOf(data.buckets);
|
||||
const timestamps = columns.map((column) => column.ts);
|
||||
const plot = plotArea(width);
|
||||
const xScale = bandScale(timestamps, plot);
|
||||
// The scale is the reported total rather than the stack's own sum: blocked
|
||||
// and cached are parts of `queries`, which a clamped `other` can undercount.
|
||||
const yScale = valueScale(Math.max(...columns.map((column) => column.queries)), [plot.bottom, plot.y]);
|
||||
const yTicks = valueTicks(yScale);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.chartRoot)}>
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
|
||||
aria-label={`Queries over time, ${data.buckets.length} buckets: ${SERIES.map((series) => series.label.toLowerCase()).join(", ")} queries per bucket`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{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)}
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={xScale}
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
<BarStack<Column, SeriesKey>
|
||||
data={columns}
|
||||
keys={SERIES.map((series) => series.key)}
|
||||
x={(column) => column.ts}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
color={(key) => SERIES_COLOR[key]}
|
||||
>
|
||||
{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)}
|
||||
{(stacks) =>
|
||||
columns.map((column, index) => (
|
||||
<Group
|
||||
key={column.ts}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
>
|
||||
{formatTick(tick.ts, data.bucket_seconds)}
|
||||
</text>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<g key={bar.bucket.ts} opacity={hovered === null || hovered === i ? 1 : 0.55}>
|
||||
{SERIES.map((series) => {
|
||||
const rect = bar.segments[series.key];
|
||||
if (rect.height <= 0) return null;
|
||||
{stacks.map((stack) => {
|
||||
const bar = stack.bars[index];
|
||||
return (
|
||||
<rect
|
||||
key={series.key}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
fill={series.color}
|
||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
<StackSegment
|
||||
key={stack.key}
|
||||
x={bar.x}
|
||||
y={bar.y}
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
fill={bar.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<rect
|
||||
key={bar.bucket.ts}
|
||||
x={bar.slot.x}
|
||||
y={bar.slot.y}
|
||||
width={bar.slot.width}
|
||||
height={bar.slot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
>
|
||||
<title>{barSummary(bar)}</title>
|
||||
</rect>
|
||||
))}
|
||||
</Group>
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(columns[hovered.index])}
|
||||
left={slotCenter(xScale, timestamps[hovered.index], plot)}
|
||||
/>
|
||||
)}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{SERIES.map((series) => (
|
||||
<li key={series.key} {...stylex.props(styles.legendItem)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
|
||||
/>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(series.color))} />
|
||||
{series.label}
|
||||
</li>
|
||||
))}
|
||||
@@ -299,24 +205,26 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
||||
<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>
|
||||
{SERIES.map((series) => (
|
||||
<th key={series.key} scope="col">
|
||||
{series.label}
|
||||
</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>
|
||||
{columns.map((column) => (
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
<td>{column.queries}</td>
|
||||
{SERIES.map((series) => (
|
||||
<td key={series.key}>{column[series.key]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</ChartRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
bandPaddingInner,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
valueTicks,
|
||||
} from "./chartKit";
|
||||
|
||||
describe("useMeasuredWidth", () => {
|
||||
/**
|
||||
* jsdom lays nothing out, so `clientWidth` is 0 there and a chart scaled to it
|
||||
* would draw a zero-width plot. Every chart test depends on this fallback.
|
||||
*/
|
||||
test("an unmeasurable container falls back to 640", () => {
|
||||
function Probe() {
|
||||
const [ref, width] = useMeasuredWidth();
|
||||
return <div ref={ref} data-testid="probe" data-width={width} />;
|
||||
}
|
||||
const { getByTestId } = render(<Probe />);
|
||||
expect(getByTestId("probe").getAttribute("data-width")).toBe("640");
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueScale", () => {
|
||||
test("the data maximum is nicened outward and the ticks land on round numbers", () => {
|
||||
const scale = valueScale(1780, [240, 0]);
|
||||
expect(scale.domain()).toEqual([0, 1800]);
|
||||
expect(scale.ticks(5)).toEqual([0, 500, 1000, 1500]);
|
||||
expect(valueTicks(scale)).toEqual([0, 500, 1000, 1500]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bandPaddingInner", () => {
|
||||
/**
|
||||
* The gap is a constant number of pixels, so the fraction it takes of one slot
|
||||
* has to fall as the slots get wider. 24 hourly columns in a 1388px plot is
|
||||
* the 24h window on a full-width panel.
|
||||
*/
|
||||
test("the fraction is two pixels per column of the plot's width", () => {
|
||||
expect(bandPaddingInner(24, 1388)).toBeCloseTo((2 * 24) / 1388, 12);
|
||||
expect(bandPaddingInner(24, 1388)).toBeCloseTo(0.034582, 6);
|
||||
});
|
||||
|
||||
/**
|
||||
* Past the cap the gap would be most of the slot and the columns would vanish
|
||||
* into slivers, so it stops at half the slot and the bars stay visible.
|
||||
*/
|
||||
test("the gap never takes more than half a slot", () => {
|
||||
expect(bandPaddingInner(1000, 100)).toBe(0.5);
|
||||
});
|
||||
|
||||
test("no columns and no width mean no gap to compute", () => {
|
||||
expect(bandPaddingInner(0, 640)).toBe(0);
|
||||
expect(bandPaddingInner(24, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("labelTickValues", () => {
|
||||
/** A week of hourly buckets in a panel the width of a laptop window. */
|
||||
test("labels thin out to one every 21 buckets at 748px of plot", () => {
|
||||
const timestamps = Array.from({ length: 168 }, (_, i) => i * 3600);
|
||||
expect(labelTickValues(timestamps, 748)).toEqual([0, 75600, 151200, 226800, 302400, 378000, 453600, 529200]);
|
||||
});
|
||||
|
||||
test("every bucket is labelled when they all fit", () => {
|
||||
expect(labelTickValues([0, 3600, 7200], 748)).toEqual([0, 3600, 7200]);
|
||||
});
|
||||
});
|
||||
|
||||
/** The frame on its own, at the width the charts draw at in jsdom. */
|
||||
function renderFrame() {
|
||||
const plot = plotArea(640);
|
||||
const timestamps = [0, 3600, 7200];
|
||||
const yScale = valueScale(100, [plot.bottom, plot.y]);
|
||||
const yTicks = valueTicks(yScale);
|
||||
const { container } = render(
|
||||
<svg width={640} height={CHART_HEIGHT}>
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={bandScale(timestamps, plot)}
|
||||
xTickValues={timestamps}
|
||||
bucketSeconds={3600}
|
||||
/>
|
||||
</svg>,
|
||||
);
|
||||
return { container, plot, yTicks };
|
||||
}
|
||||
|
||||
describe("ChartFrame", () => {
|
||||
test("the value axis is bare: no axis line, no tick marks, labels 6px left of the plot", () => {
|
||||
const { container, plot, yTicks } = renderFrame();
|
||||
|
||||
const axis = container.querySelector(".visx-axis-left") as SVGGElement;
|
||||
expect(axis.getAttribute("transform")).toBe(`translate(${plot.x}, 0)`);
|
||||
expect(axis.querySelector("line")).toBeNull();
|
||||
|
||||
const labels = Array.from(axis.querySelectorAll("text"));
|
||||
expect(labels.map((label) => label.textContent)).toEqual(yTicks.map(String));
|
||||
for (const label of labels) {
|
||||
expect(label.getAttribute("x")).toBe("0");
|
||||
expect(label.getAttribute("dx")).toBe("-6px");
|
||||
expect(label.getAttribute("text-anchor")).toBe("end");
|
||||
// The label sits on the tick, centred: visx's own 0.25em nudge is
|
||||
// cancelled so it does not double up with the middle baseline.
|
||||
expect(label.getAttribute("dy")).toBe("0");
|
||||
expect(label.getAttribute("dominant-baseline")).toBe("middle");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The time axis keeps its baseline and drops its tick marks, and the labels
|
||||
* are placed by the group transform plus `dy` rather than by the font-size
|
||||
* guess visx would otherwise make.
|
||||
*/
|
||||
test("the time axis keeps only its baseline, and its labels sit 16px below it", () => {
|
||||
const { container, plot } = renderFrame();
|
||||
|
||||
const axis = container.querySelector(".visx-axis-bottom") as SVGGElement;
|
||||
expect(axis.getAttribute("transform")).toBe(`translate(0, ${plot.bottom})`);
|
||||
|
||||
const lines = Array.from(axis.querySelectorAll("line"));
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].getAttribute("class")).toContain("visx-axis-line");
|
||||
|
||||
for (const label of Array.from(axis.querySelectorAll("text"))) {
|
||||
expect(label.getAttribute("y")).toBe("0");
|
||||
expect(label.getAttribute("dy")).toBe("16px");
|
||||
expect(label.getAttribute("text-anchor")).toBe("middle");
|
||||
}
|
||||
});
|
||||
|
||||
test("every grid line has a labelled tick on it and no tick floats without a line", () => {
|
||||
const { container } = renderFrame();
|
||||
|
||||
const gridY = Array.from(container.querySelectorAll(".visx-rows line")).map((line) => line.getAttribute("y1"));
|
||||
const labelY = Array.from(container.querySelectorAll(".visx-axis-left text")).map((label) =>
|
||||
label.getAttribute("y"),
|
||||
);
|
||||
expect(gridY.length).toBeGreaterThan(0);
|
||||
expect(gridY).toEqual(labelY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* The plumbing the two bar charts on Overview share: the width measurement, the
|
||||
* scales, the axis and grid chrome, the segment separator, the per-bucket hit
|
||||
* target and the tooltip.
|
||||
*
|
||||
* Geometry is visx's; the chrome is ours. visx's own axis defaults draw tick
|
||||
* marks, an axis line on both axes and Arial 10px in #222, none of which this
|
||||
* app wants, so every wrapper here turns those off and dresses the result in
|
||||
* StyleX tokens instead.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
|
||||
import { GridRows } from "@visx/grid";
|
||||
import { scaleBand, scaleLinear } from "@visx/scale";
|
||||
import { TooltipWithBounds } from "@visx/tooltip";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
export const CHART_HEIGHT = 240;
|
||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||
|
||||
/** The width a chart draws at before a measurement exists — jsdom, and the first paint. */
|
||||
const FALLBACK_WIDTH = 640;
|
||||
|
||||
/** How many pixels one x-axis label needs to itself before the labels thin out. */
|
||||
const MIN_X_LABEL_PX = 90;
|
||||
|
||||
/** The gap between two touching columns, in pixels of the drawn plot. */
|
||||
const BAR_GAP_PX = 2;
|
||||
|
||||
const Y_TICK_COUNT = 5;
|
||||
|
||||
export interface Plot {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** The y of the baseline: the bottom edge of the plot. */
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function plotArea(width: number): Plot {
|
||||
const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom);
|
||||
return {
|
||||
x: MARGIN.left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||
height,
|
||||
bottom: MARGIN.top + height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The container's width, measured on mount and on every resize. Deliberately
|
||||
* `useEffect` rather than `useLayoutEffect`: the first paint draws at the
|
||||
* fallback width and the measured width lands a frame later, which is the
|
||||
* timing the charts have always had.
|
||||
*/
|
||||
export function useMeasuredWidth(): [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 > 0 ? width : FALLBACK_WIDTH];
|
||||
}
|
||||
|
||||
/**
|
||||
* The value axis. `nice: true` rounds the domain outward, so the `max` passed
|
||||
* here is the data's own maximum and not a pre-rounded one.
|
||||
*/
|
||||
export function valueScale(max: number, range: [number, number]) {
|
||||
return scaleLinear<number>({ domain: [0, max], range, nice: true });
|
||||
}
|
||||
|
||||
export type ValueScale = ReturnType<typeof valueScale>;
|
||||
|
||||
/** The tick values the grid and the value axis both draw. */
|
||||
export function valueTicks(scale: ValueScale): number[] {
|
||||
return scale.ticks(Y_TICK_COUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide the gap between two columns is, as a fraction of one column's slot.
|
||||
* A constant would make the gap grow with the column: 30 daily columns and 60
|
||||
* minute columns are drawn at the same pixel gap, so the fraction has to be
|
||||
* computed from how many columns share the plot.
|
||||
*/
|
||||
export function bandPaddingInner(bucketCount: number, plotWidth: number): number {
|
||||
if (bucketCount === 0 || plotWidth <= 0) return 0;
|
||||
return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth);
|
||||
}
|
||||
|
||||
/** The time axis: one band per bucket, in the order the buckets were given. */
|
||||
export function bandScale(values: number[], plot: Plot) {
|
||||
return scaleBand<number>({
|
||||
domain: values,
|
||||
range: [plot.x, plot.x + plot.width],
|
||||
paddingInner: bandPaddingInner(values.length, plot.width),
|
||||
});
|
||||
}
|
||||
|
||||
export type BandScale = ReturnType<typeof bandScale>;
|
||||
|
||||
/**
|
||||
* The subset of bucket timestamps that get an 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.
|
||||
*/
|
||||
export function labelTickValues(values: number[], plotWidth: number): number[] {
|
||||
if (values.length === 0 || plotWidth <= 0) return values.slice(0, 1);
|
||||
const step = Math.max(1, Math.ceil((values.length * MIN_X_LABEL_PX) / plotWidth));
|
||||
return values.filter((_, i) => i % step === 0);
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
export function formatBucketTime(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);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
tooltip: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: 10,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
tooltipTitle: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
tooltipList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.125rem",
|
||||
marginTop: "0.25rem",
|
||||
},
|
||||
tooltipRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
},
|
||||
tooltipTerm: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
width: "0.5rem",
|
||||
height: "0.5rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
/** Both charts' "nothing here" state, which is also where the ref to measure lives. */
|
||||
export function EmptyChart({ containerRef }: { containerRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartRoot({
|
||||
containerRef,
|
||||
children,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.root)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* visx's `Ticks` derives a label's `y` from a guessed font size, which is wrong
|
||||
* here because the size comes from a stylesheet it cannot read. The label is
|
||||
* therefore placed by the axis group's own transform plus the `dy` the caller
|
||||
* passes, and the guessed `y` is dropped.
|
||||
*/
|
||||
function TickLabel({ x, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) {
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={0}
|
||||
dx={dx}
|
||||
dy={dy}
|
||||
textAnchor={textAnchor}
|
||||
dominantBaseline={dominantBaseline}
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
>
|
||||
{formattedValue}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/** As `TickLabel`, but keeping the scaled tick position the vertical axis puts in `y`. */
|
||||
function ValueTickLabel({ x, y, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) {
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dx={dx}
|
||||
dy={dy}
|
||||
textAnchor={textAnchor}
|
||||
dominantBaseline={dominantBaseline}
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
>
|
||||
{formattedValue}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid and the two axes. The grid lines and the value labels are computed
|
||||
* from one tick array, so every drawn line has a number beside it and no number
|
||||
* floats without one.
|
||||
*/
|
||||
export function ChartFrame({
|
||||
plot,
|
||||
yScale,
|
||||
yTicks,
|
||||
xScale,
|
||||
xTickValues,
|
||||
bucketSeconds,
|
||||
}: {
|
||||
plot: Plot;
|
||||
yScale: ValueScale;
|
||||
yTicks: number[];
|
||||
xScale: BandScale;
|
||||
xTickValues: number[];
|
||||
bucketSeconds: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<GridRows scale={yScale} tickValues={yTicks} left={plot.x} width={plot.width} stroke={colors.border} />
|
||||
<AxisLeft
|
||||
scale={yScale}
|
||||
left={plot.x}
|
||||
tickValues={yTicks}
|
||||
numTicks={Y_TICK_COUNT}
|
||||
hideAxisLine
|
||||
hideTicks
|
||||
tickLength={0}
|
||||
tickFormat={(value) => compact.format(Number(value))}
|
||||
// `dy` overrides AxisLeft's own 0.25em nudge, which would double up
|
||||
// with the middle baseline this app centres its value labels on.
|
||||
tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }}
|
||||
tickComponent={ValueTickLabel}
|
||||
/>
|
||||
<AxisBottom
|
||||
scale={xScale}
|
||||
top={plot.bottom}
|
||||
tickValues={xTickValues}
|
||||
hideTicks
|
||||
tickLength={0}
|
||||
stroke={colors.borderStrong}
|
||||
tickFormat={(value) => formatBucketTime(Number(value), bucketSeconds)}
|
||||
tickLabelProps={{ dy: "16px", textAnchor: "middle" }}
|
||||
tickComponent={TickLabel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One segment of a stacked column. The separator is drawn only once the column
|
||||
* is wide enough for two neighbouring segments to read as two shapes; below
|
||||
* that it would be most of the bar.
|
||||
*/
|
||||
export function StackSegment({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
fill,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
}) {
|
||||
if (height <= 0) return null;
|
||||
return (
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
fill={fill}
|
||||
strokeWidth={width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transparent hit targets: one per bucket, spanning the whole plot height so
|
||||
* that pointing at the empty space above a short column still selects it.
|
||||
*
|
||||
* A slot is a whole band step, gap included, so that no pixel between two
|
||||
* columns belongs to neither. The last slot is clipped to the plot's right edge:
|
||||
* the band scale spends the trailing gap on nothing, and a full-step rect there
|
||||
* would reach into the right margin.
|
||||
*/
|
||||
export function BucketOverlay({
|
||||
plot,
|
||||
values,
|
||||
xScale,
|
||||
onEnter,
|
||||
}: {
|
||||
plot: Plot;
|
||||
values: number[];
|
||||
xScale: BandScale;
|
||||
onEnter: (index: number) => void;
|
||||
}) {
|
||||
const step = xScale.step();
|
||||
const right = plot.x + plot.width;
|
||||
return (
|
||||
<>
|
||||
{values.map((value, index) => {
|
||||
const x = xScale(value) ?? plot.x;
|
||||
return (
|
||||
<rect
|
||||
key={value}
|
||||
x={x}
|
||||
y={plot.y}
|
||||
width={Math.max(0, Math.min(step, right - x))}
|
||||
height={plot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => onEnter(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The x a bucket's tooltip points at: the centre of its slot, not of its narrower bar. */
|
||||
export function slotCenter(xScale: BandScale, value: number, plot: Plot): number {
|
||||
return (xScale(value) ?? plot.x) + xScale.step() / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which item the pointer is on, and nothing else — a bucket in the bar charts, a
|
||||
* slice in the donuts.
|
||||
*
|
||||
* Deliberately not `useTooltip`: holding the hovered item's numbers and screen
|
||||
* position in state decouples them from the data. The window refreshes every
|
||||
* half minute and the container can be resized under an open tooltip, and either
|
||||
* one leaves a stored copy describing an item that has moved or changed. Only
|
||||
* the index is kept, so the caller reads the values and the coordinates out of
|
||||
* the render it is currently drawing.
|
||||
*
|
||||
* `windowKey` is what makes a changing set safe: when the items themselves
|
||||
* change, index `i` no longer names the one the pointer was over, so the
|
||||
* selection made against the old key is dropped and the tooltip goes away.
|
||||
*/
|
||||
export function useActiveIndex(windowKey: string) {
|
||||
const [selection, setSelection] = useState<{ index: number; key: string } | null>(null);
|
||||
// A selection belongs to the window it was made in. Deleting it as soon as the
|
||||
// key moves on is what stops a returning key — a resize back to a previous
|
||||
// width, a poll that restores a bucket count — from reviving a hover the
|
||||
// pointer never made.
|
||||
if (selection !== null && selection.key !== windowKey) setSelection(null);
|
||||
return {
|
||||
index: selection !== null && selection.key === windowKey ? selection.index : null,
|
||||
show: (index: number) => setSelection({ index, key: windowKey }),
|
||||
clear: () => setSelection(null),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TooltipRow {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Omitted for a row that names no drawn shape, such as a bucket's total. */
|
||||
color?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** What a tooltip says, whichever chart opened it: a heading and its rows. */
|
||||
export interface TooltipContent {
|
||||
title: string;
|
||||
rows: TooltipRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `TooltipWithBounds` positions itself with an inline transform and drops it
|
||||
* when `unstyled` is set, so the default look is replaced by handing it an empty
|
||||
* style object rather than by turning styling off.
|
||||
*/
|
||||
const NO_INLINE_STYLE = {};
|
||||
|
||||
/** The gap the tooltip keeps from its anchor point. */
|
||||
const TOOLTIP_OFFSET = 8;
|
||||
|
||||
export function ChartTooltip({
|
||||
content,
|
||||
index,
|
||||
left,
|
||||
top = 0,
|
||||
}: {
|
||||
content: TooltipContent;
|
||||
/** Which item the tooltip names; part of what forces a fresh measurement. */
|
||||
index: number;
|
||||
left: number;
|
||||
top?: number;
|
||||
}) {
|
||||
// `withBoundingRects` measures once, in `componentDidMount`, and never again,
|
||||
// so every content change needs its own mount to be measured at its own size.
|
||||
// The index alone is not enough: a refresh can widen the same item's text past
|
||||
// a digit boundary, or resolve a client's name, and the stale width would place
|
||||
// it wrongly at the right edge.
|
||||
const measureKey = [index, content.title, ...content.rows.map((row) => `${row.label}=${row.value}`)].join("|");
|
||||
return (
|
||||
<TooltipWithBounds
|
||||
key={measureKey}
|
||||
left={left}
|
||||
top={top}
|
||||
offsetLeft={TOOLTIP_OFFSET}
|
||||
offsetTop={TOOLTIP_OFFSET}
|
||||
style={NO_INLINE_STYLE}
|
||||
className={stylex.props(styles.tooltip).className}
|
||||
>
|
||||
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
|
||||
<dl {...stylex.props(styles.tooltipList)}>
|
||||
{content.rows.map((row) => (
|
||||
<div key={row.key} {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>
|
||||
{row.color !== undefined && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
|
||||
/>
|
||||
)}
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</TooltipWithBounds>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { Bucket } from "@/lib/types";
|
||||
import { MARGIN, isEmptyTimeseries, layoutTimeseries, niceTicks } from "./chartLayout";
|
||||
|
||||
function bucket(ts: number, queries: number, blocked = 0, cached = 0): Bucket {
|
||||
return { ts, queries, blocked, cached };
|
||||
}
|
||||
|
||||
describe("niceTicks", () => {
|
||||
test("zero max yields a single zero tick", () => {
|
||||
expect(niceTicks(0)).toEqual([0]);
|
||||
});
|
||||
|
||||
test("picks a 1/2/5 step and extends past max", () => {
|
||||
expect(niceTicks(7)).toEqual([0, 2, 4, 6, 8]);
|
||||
expect(niceTicks(100)).toEqual([0, 50, 100]);
|
||||
expect(niceTicks(1234)).toEqual([0, 500, 1000, 1500]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmptyTimeseries", () => {
|
||||
test("true for no buckets and for all-zero buckets", () => {
|
||||
expect(isEmptyTimeseries([])).toBe(true);
|
||||
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 0)])).toBe(true);
|
||||
});
|
||||
|
||||
test("false when any bucket has queries", () => {
|
||||
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 3)])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layoutTimeseries", () => {
|
||||
test("segment heights are proportional and stack to the queries total", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 100, 40, 10), bucket(60, 50, 0, 0)], 480, 240);
|
||||
const plotHeight = 240 - MARGIN.top - MARGIN.bottom;
|
||||
const baseline = MARGIN.top + plotHeight;
|
||||
const [first, second] = layout.bars;
|
||||
|
||||
expect(layout.scaleMax).toBe(100);
|
||||
expect(first.other).toBe(50);
|
||||
expect(first.segments.blocked.height).toBeCloseTo(plotHeight * 0.4);
|
||||
expect(first.segments.cached.height).toBeCloseTo(plotHeight * 0.1);
|
||||
expect(first.segments.other.height).toBeCloseTo(plotHeight * 0.5);
|
||||
expect(first.segments.blocked.y + first.segments.blocked.height).toBeCloseTo(baseline);
|
||||
expect(first.segments.cached.y + first.segments.cached.height).toBeCloseTo(first.segments.blocked.y);
|
||||
expect(first.segments.other.y + first.segments.other.height).toBeCloseTo(first.segments.cached.y);
|
||||
expect(first.segments.other.y).toBeCloseTo(MARGIN.top);
|
||||
expect(second.segments.other.height).toBeCloseTo(plotHeight * 0.5);
|
||||
});
|
||||
|
||||
test("clamps other at zero when blocked + cached exceed queries", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 10, 8, 5)], 480, 240);
|
||||
expect(layout.bars[0].other).toBe(0);
|
||||
expect(layout.bars[0].segments.other.height).toBe(0);
|
||||
});
|
||||
|
||||
test("zero data still lays out zero-height bars on a unit scale", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 0), bucket(60, 0)], 480, 240);
|
||||
expect(layout.scaleMax).toBe(1);
|
||||
expect(layout.bars).toHaveLength(2);
|
||||
for (const bar of layout.bars) {
|
||||
expect(bar.segments.blocked.height).toBe(0);
|
||||
expect(bar.segments.cached.height).toBe(0);
|
||||
expect(bar.segments.other.height).toBe(0);
|
||||
}
|
||||
expect(layout.yTicks).toEqual([{ value: 0, y: MARGIN.top + (240 - MARGIN.top - MARGIN.bottom) }]);
|
||||
});
|
||||
|
||||
test("single bucket fills the plot width minus the gap", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 5, 1, 1)], 480, 240);
|
||||
const plotWidth = 480 - MARGIN.left - MARGIN.right;
|
||||
const bar = layout.bars[0];
|
||||
expect(bar.slot.width).toBeCloseTo(plotWidth);
|
||||
expect(bar.segments.blocked.width).toBeCloseTo(plotWidth - 2);
|
||||
expect(bar.segments.blocked.x).toBeCloseTo(MARGIN.left + 1);
|
||||
expect(layout.xTicks).toEqual([{ ts: 0, x: MARGIN.left + plotWidth / 2 }]);
|
||||
});
|
||||
|
||||
test("x ticks thin out when buckets outnumber the label budget", () => {
|
||||
const buckets = Array.from({ length: 168 }, (_, i) => bucket(i * 3600, i));
|
||||
const layout = layoutTimeseries(buckets, 800, 240);
|
||||
expect(layout.xTicks.length).toBeLessThan(buckets.length / 10);
|
||||
expect(layout.xTicks[0].ts).toBe(0);
|
||||
const xs = layout.xTicks.map((tick) => tick.x);
|
||||
expect([...xs].sort((a, b) => a - b)).toEqual(xs);
|
||||
});
|
||||
|
||||
test("empty bucket list yields no bars and no x ticks", () => {
|
||||
const layout = layoutTimeseries([], 480, 240);
|
||||
expect(layout.bars).toEqual([]);
|
||||
expect(layout.xTicks).toEqual([]);
|
||||
expect(layout.scaleMax).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { Bucket } from "@/lib/types";
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BarLayout {
|
||||
bucket: Bucket;
|
||||
/** queries - blocked - cached, clamped at 0. */
|
||||
other: number;
|
||||
slot: Rect;
|
||||
segments: {
|
||||
blocked: Rect;
|
||||
cached: Rect;
|
||||
other: Rect;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChartLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
plot: Rect;
|
||||
scaleMax: number;
|
||||
bars: BarLayout[];
|
||||
yTicks: { value: number; y: number }[];
|
||||
xTicks: { ts: number; x: number }[];
|
||||
}
|
||||
|
||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||
const BAR_GAP = 2;
|
||||
const MIN_X_LABEL_PX = 90;
|
||||
|
||||
/** Tick values from 0 upward in a 1/2/5 step, extended until the last tick covers `max`. */
|
||||
export function niceTicks(max: number, targetCount = 4): number[] {
|
||||
if (max <= 0) return [0];
|
||||
const rawStep = max / targetCount;
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||
const normalized = rawStep / magnitude;
|
||||
const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
|
||||
const ticks: number[] = [];
|
||||
for (let value = 0; ; value += step) {
|
||||
ticks.push(value);
|
||||
if (value >= max) break;
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function isEmptyTimeseries(buckets: Bucket[]): boolean {
|
||||
return buckets.every((bucket) => bucket.queries === 0);
|
||||
}
|
||||
|
||||
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);
|
||||
const baseline = plot.y + plot.height;
|
||||
const toHeight = (value: number) => (value / scaleMax) * plot.height;
|
||||
|
||||
const slotWidth = buckets.length > 0 ? plot.width / buckets.length : 0;
|
||||
const barWidth = Math.max(1, slotWidth - BAR_GAP);
|
||||
|
||||
const bars: BarLayout[] = buckets.map((bucket, i) => {
|
||||
const slotX = plot.x + i * slotWidth;
|
||||
const barX = slotX + (slotWidth - barWidth) / 2;
|
||||
const other = Math.max(0, bucket.queries - bucket.blocked - bucket.cached);
|
||||
const blockedH = toHeight(bucket.blocked);
|
||||
const cachedH = toHeight(bucket.cached);
|
||||
const otherH = toHeight(other);
|
||||
return {
|
||||
bucket,
|
||||
other,
|
||||
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
|
||||
segments: {
|
||||
blocked: { x: barX, y: baseline - blockedH, width: barWidth, height: blockedH },
|
||||
cached: { x: barX, y: baseline - blockedH - cachedH, width: barWidth, height: cachedH },
|
||||
other: { x: barX, y: baseline - blockedH - cachedH - otherH, width: barWidth, height: otherH },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const yTicks = tickValues.map((value) => ({ value, y: baseline - toHeight(value) }));
|
||||
|
||||
const labelStep = 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 }));
|
||||
|
||||
return { width, height, plot, scaleMax, bars, yTicks, xTicks };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,74 +1,41 @@
|
||||
/**
|
||||
* 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.
|
||||
* The hook over the single `/api/overview` request.
|
||||
*
|
||||
* The five-endpoint build reconciled five window identities here — the retry per
|
||||
* mismatch episode, the terminal "different window" error, the orphaned stale
|
||||
* completion. One request cannot disagree with itself, so those behaviours have
|
||||
* no subject left and are gone rather than ported. What survived the collapse is
|
||||
* pinned below: the three states, and the one rule a single request still does
|
||||
* not settle — that a `keepPreviousData` body from the period the reader left
|
||||
* must never render under the new period's label.
|
||||
*/
|
||||
|
||||
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";
|
||||
import type { Period } from "@/lib/types";
|
||||
import { useOverviewWindow } 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;
|
||||
}
|
||||
let failing: boolean;
|
||||
let calls: 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=",
|
||||
function body(period: Period): unknown {
|
||||
return {
|
||||
period,
|
||||
since: SINCE,
|
||||
until: UNTIL,
|
||||
bucket_seconds: 1800,
|
||||
totals: { queries: 10, blocked: 2, clients: 1, avg_response_time_us: 1000 },
|
||||
buckets: [],
|
||||
clients: [],
|
||||
other: [],
|
||||
types: [],
|
||||
routes: [],
|
||||
coverage: { complete: true, available_since: SINCE },
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -76,55 +43,36 @@ function json(payload: unknown, status = 200): Response {
|
||||
}
|
||||
|
||||
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 };
|
||||
failing = false;
|
||||
calls = 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 };
|
||||
if (!url.startsWith("/api/overview")) return json({ error: "not stubbed" }, 404);
|
||||
calls += 1;
|
||||
if (failing) return json({ error: "endpoint unavailable" }, 400);
|
||||
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;
|
||||
return json(body(period));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
/** The last retry the hook handed out, so a test can spend it. */
|
||||
let lastRetry: () => void;
|
||||
|
||||
function Probe({ period }: { period: Period }) {
|
||||
const overview = useOverviewWindow(period);
|
||||
return (
|
||||
<ul>
|
||||
{OVERVIEW_ENDPOINTS.map((endpoint) => {
|
||||
const panel = overview[endpoint];
|
||||
const panel = useOverviewWindow(period);
|
||||
if (panel.status === "error") lastRetry = panel.retry;
|
||||
const detail =
|
||||
panel.status === "ready"
|
||||
? `${panel.data.period}@${panel.data.until}/${panel.data.coverage.available_since}`
|
||||
? `${panel.data.period}@${panel.data.until}`
|
||||
: panel.status === "error"
|
||||
? (panel.error as Error).message
|
||||
: "";
|
||||
return <li key={endpoint}>{`${endpoint}:${panel.status}:${detail}`}</li>;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
return <p>{`${panel.status}:${detail}`}</p>;
|
||||
}
|
||||
|
||||
function renderProbe(period: Period = "24h") {
|
||||
@@ -144,149 +92,36 @@ function renderProbe(period: Period = "24h") {
|
||||
};
|
||||
}
|
||||
|
||||
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 ?? "";
|
||||
function line(): string {
|
||||
return screen.getByRole("paragraph").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 () => {
|
||||
test("the page is loading until the body for the selected period arrives", async () => {
|
||||
renderProbe();
|
||||
await waitFor(() => expect(line("totals")).toContain("ready"));
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
expect(line(endpoint)).toBe(`${endpoint}:ready:24h@${UNTIL}/${SINCE}`);
|
||||
}
|
||||
expect(line()).toBe("loading:");
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
});
|
||||
|
||||
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");
|
||||
test("a failed request is one error for the whole page, with a retry that refetches", async () => {
|
||||
failing = true;
|
||||
renderProbe();
|
||||
|
||||
await waitFor(() => expect(line("routes")).toContain("ready"));
|
||||
expect(calls.routes).toBe(2);
|
||||
expect(calls.totals).toBe(1);
|
||||
});
|
||||
await waitFor(() => expect(line()).toBe("error:endpoint unavailable"));
|
||||
const spent = calls;
|
||||
|
||||
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");
|
||||
failing = false;
|
||||
lastRetry();
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
expect(calls).toBeGreaterThan(spent);
|
||||
});
|
||||
|
||||
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}`));
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
|
||||
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"));
|
||||
// `keepPreviousData` is holding the 24h body. It is a complete answer and
|
||||
// still the wrong one to draw under "1h", so the page waits.
|
||||
expect(line()).toBe("loading:");
|
||||
await waitFor(() => expect(line()).toBe(`ready:1h@${UNTIL}`));
|
||||
});
|
||||
|
||||
@@ -1,249 +1,34 @@
|
||||
/**
|
||||
* One period, five requests, one window.
|
||||
* One period, one request, 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.
|
||||
* The five per-panel endpoints this replaces could each answer for a different
|
||||
* span, so the page had to reconcile five window identities, retry the laggards
|
||||
* and fail the ones that stayed behind. `GET /api/overview` answers every panel
|
||||
* out of a single read transaction: the totals, both timelines and both
|
||||
* breakdowns describe the same span and the same database state by construction,
|
||||
* and none of that reconciliation has anything left to reconcile.
|
||||
*
|
||||
* 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.
|
||||
* What remains is the one rule a single request does not settle by itself.
|
||||
* `keepPreviousData` holds the body of the period the reader just left — a
|
||||
* complete, self-consistent answer, and still the wrong one to draw under the
|
||||
* new label — so a body is a member of this window only while its own `period`
|
||||
* is the selected one. Until then the page is loading.
|
||||
*/
|
||||
|
||||
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}`;
|
||||
}
|
||||
import { useCallback } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { overviewQuery } from "@/lib/queries";
|
||||
import type { Overview, Period } from "@/lib/types";
|
||||
|
||||
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>;
|
||||
}
|
||||
export function useOverviewWindow(period: Period): Panel<Overview> {
|
||||
const query = useQuery({ ...overviewQuery(period), placeholderData: keepPreviousData });
|
||||
const { refetch } = query;
|
||||
const retry = useCallback(() => void refetch(), [refetch]);
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
if (query.isError) return { status: "error", error: query.error, retry };
|
||||
if (query.data !== undefined && query.data.period === period) return { status: "ready", data: query.data };
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -91,11 +91,14 @@ test("unpaused: duration menu pauses with the picked duration_seconds", async ()
|
||||
|
||||
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();
|
||||
}
|
||||
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
|
||||
"60 seconds",
|
||||
"5 minutes",
|
||||
"30 minutes",
|
||||
"Indefinitely",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
@@ -105,7 +108,7 @@ test("indefinite pause sends no duration_seconds", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Indefinitely" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
@@ -151,11 +154,15 @@ test("an active resolver states nothing: the button already says Pause", async (
|
||||
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();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
// Opening the menu moves focus into it, so Escape is pressed where the reader
|
||||
// is, not on the trigger they left.
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Pause" }).getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
@@ -168,7 +175,7 @@ test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||
@@ -187,7 +194,7 @@ test("failed pause with 503 shows the degraded message", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "60 seconds" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||
@@ -202,12 +209,12 @@ test("a successful pause clears the previous mutation error", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
await screen.findByRole("alert");
|
||||
|
||||
postFailure = null;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
@@ -240,7 +247,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
protection = { state: "unavailable", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
@@ -253,7 +260,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
|
||||
// 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();
|
||||
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -261,7 +268,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
// Filtering is paused from somewhere else, and this browser learns it from
|
||||
// the poll. The Resume rendering has no menu.
|
||||
@@ -274,7 +281,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
|
||||
|
||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* The Pause/Resume control, at the foot of the sidebar. A pause stops filtering
|
||||
* for the whole resolver, so it belongs to the shell rather than to any page,
|
||||
* and it has no second placement.
|
||||
*
|
||||
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
|
||||
* the Diagnostics health strip, and it renders nothing at all while protection
|
||||
@@ -20,9 +19,10 @@
|
||||
* line: the button says Pause, which is the whole message.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
||||
import { formatClock } from "@/lib/format";
|
||||
import { pauseMutation } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
@@ -48,6 +48,8 @@ const styles = stylex.create({
|
||||
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||
},
|
||||
/** The sidebar foot is the only placement, and there Log out sets the width. */
|
||||
width: "100%",
|
||||
},
|
||||
row: {
|
||||
display: "flex",
|
||||
@@ -61,43 +63,44 @@ const styles = stylex.create({
|
||||
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",
|
||||
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
|
||||
popover: {
|
||||
width: "var(--trigger-width)",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingBlock: "0.25rem",
|
||||
color: colors.text,
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menu: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
menuItem: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/**
|
||||
* RAC focuses the item's own node, so the shared ring applies; it is inset
|
||||
* because an item flush against the popover edge clips an outset one, and
|
||||
* recoloured because the focus token is the blue this row just painted.
|
||||
*/
|
||||
menuItemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
});
|
||||
|
||||
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]);
|
||||
@@ -108,15 +111,6 @@ export default function PauseControl() {
|
||||
// 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) {
|
||||
@@ -140,41 +134,40 @@ export default function PauseControl() {
|
||||
}
|
||||
|
||||
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)}
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
isDisabled={mutation.isPending}
|
||||
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
<Menu {...stylex.props(styles.menu)}>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<button
|
||||
<MenuItem
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onAction={() =>
|
||||
mutation.mutate(
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
||||
seconds === null
|
||||
? { paused: true }
|
||||
: { paused: true, duration_seconds: seconds },
|
||||
)
|
||||
}
|
||||
className={({ isFocused }) =>
|
||||
stylex.props(
|
||||
styles.menuItem,
|
||||
shared.insetFocusRing,
|
||||
isFocused && styles.menuItemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,12 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** The same sentence with no box, for a results footer that already has one. */
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -27,11 +33,21 @@ const styles = stylex.create({
|
||||
* 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.
|
||||
*
|
||||
* `note` is the same sentence without the panel, for a place that already sits
|
||||
* under the data it qualifies — a results footer states the reach of the count
|
||||
* beside it, where a full-width banner would announce a limit as news.
|
||||
*/
|
||||
export default function CoverageNotice({ coverage }: { coverage: Coverage }) {
|
||||
export default function CoverageNotice({
|
||||
coverage,
|
||||
variant = "banner",
|
||||
}: {
|
||||
coverage: Coverage;
|
||||
variant?: "banner" | "note";
|
||||
}) {
|
||||
if (coverage.complete) return null;
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.notice)}>
|
||||
<p role="status" {...stylex.props(variant === "note" ? styles.note : styles.notice)}>
|
||||
Query history is available from {formatTime(coverage.available_since)}.
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ const styles = stylex.create({
|
||||
color: colors.danger,
|
||||
},
|
||||
retry: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiError, deleteGroup, getQueries, getStats, listGroups, login, putGroupSources } from "@/lib/api";
|
||||
import { ApiError, deleteGroup, getOverview, getQueries, listGroups, login, putGroupSources } from "@/lib/api";
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
@@ -50,7 +50,7 @@ test("falls back to a status message on a non-JSON error body", async () => {
|
||||
|
||||
test("parses Retry-After on 429", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "17" }));
|
||||
const failure = await getStats("1h").catch((e: unknown) => e);
|
||||
const failure = await getOverview("1h").catch((e: unknown) => e);
|
||||
expect(failure).toBeInstanceOf(ApiError);
|
||||
expect((failure as ApiError).status).toBe(429);
|
||||
expect((failure as ApiError).retryAfter).toBe(17);
|
||||
@@ -58,7 +58,7 @@ test("parses Retry-After on 429", async () => {
|
||||
|
||||
test("ignores a malformed Retry-After header", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "soon" }));
|
||||
const failure = await getStats().catch((e: unknown) => e);
|
||||
const failure = await getOverview().catch((e: unknown) => e);
|
||||
expect((failure as ApiError).retryAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
+4
-13
@@ -23,6 +23,7 @@ import type {
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
LookupResult,
|
||||
Overview,
|
||||
PausePost,
|
||||
PauseState,
|
||||
Period,
|
||||
@@ -35,11 +36,6 @@ import type {
|
||||
SettingsEnvelope,
|
||||
SettingsPatch,
|
||||
SourceStatus,
|
||||
StatsClients,
|
||||
StatsRoutes,
|
||||
StatsTimeseries,
|
||||
StatsTotals,
|
||||
StatsTypes,
|
||||
Upstream,
|
||||
UpstreamEcho,
|
||||
UpstreamInput,
|
||||
@@ -112,7 +108,7 @@ export const login = (body: LoginRequest): Promise<LoginResponse> =>
|
||||
request("/api/auth/login", { method: "POST", body });
|
||||
export const logout = (): Promise<LogoutResponse> => request("/api/auth/logout", { method: "POST", body: {} });
|
||||
|
||||
// Query log + stats
|
||||
// Query log + overview
|
||||
|
||||
export const getQueries = (filter: QueriesFilter = {}): Promise<QueriesPage> =>
|
||||
request(`/api/queries${qs({ ...filter })}`);
|
||||
@@ -123,13 +119,8 @@ export const getQueryDetail = (id: number): Promise<QueryDetail> => request(`/ap
|
||||
/** `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 })}`);
|
||||
/** Every Overview panel for one window, from one read transaction. */
|
||||
export const getOverview = (period?: Period): Promise<Overview> => request(`/api/overview${qs({ period })}`);
|
||||
|
||||
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
|
||||
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
LookupResult,
|
||||
Overview,
|
||||
PauseState,
|
||||
QueriesPage,
|
||||
QueryDetail,
|
||||
@@ -36,11 +37,6 @@ import type {
|
||||
RuleEcho,
|
||||
SettingsEnvelope,
|
||||
SourceStatus,
|
||||
StatsClients,
|
||||
StatsRoutes,
|
||||
StatsTimeseries,
|
||||
StatsTotals,
|
||||
StatsTypes,
|
||||
Upstream,
|
||||
UpstreamEcho,
|
||||
Version,
|
||||
@@ -441,7 +437,7 @@ export const sample_create_upstream: UpstreamEcho = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
restart_required: true,
|
||||
restart_required: false,
|
||||
tls_name: "",
|
||||
url: "https://dns2.example/dns-query",
|
||||
};
|
||||
@@ -458,7 +454,7 @@ export const sample_update_upstream: UpstreamEcho = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
restart_required: true,
|
||||
restart_required: false,
|
||||
tls_name: "",
|
||||
url: "https://dns.example/dns-query",
|
||||
};
|
||||
@@ -588,39 +584,6 @@ export const sample_get_query_detail: QueryDetail = {
|
||||
},
|
||||
};
|
||||
|
||||
export const sample_get_stats: StatsTotals = {
|
||||
avg_response_time_us: null,
|
||||
blocked: 0,
|
||||
clients: 0,
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: true,
|
||||
},
|
||||
period: "1h",
|
||||
queries: 0,
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_stats_timeseries: StatsTimeseries = {
|
||||
bucket_seconds: 0,
|
||||
buckets: [
|
||||
{
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
queries: 0,
|
||||
ts: 0,
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: true,
|
||||
},
|
||||
period: "1h",
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_pause: PauseState = {
|
||||
paused: false,
|
||||
until: null,
|
||||
@@ -633,51 +596,18 @@ export const sample_post_pause: PauseState = {
|
||||
|
||||
export const sample_get_settings: SettingsEnvelope = {
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"dns.rate_limit",
|
||||
"dns.rate_window_seconds",
|
||||
"blocking.response",
|
||||
"blocking.ttl",
|
||||
"cache.size",
|
||||
"cache.negative_ttl_max",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.api_localhost_exempt",
|
||||
"web.sse_max_connections_per_ip",
|
||||
"web.trusted_proxies",
|
||||
"doh_server.enabled",
|
||||
"doh_server.bind",
|
||||
"doh_server.port",
|
||||
"doh_server.cert_path",
|
||||
"doh_server.key_path",
|
||||
"dot_server.enabled",
|
||||
"dot_server.bind",
|
||||
"dot_server.port",
|
||||
"dot_server.cert_path",
|
||||
"dot_server.key_path",
|
||||
"edns.ecs_mode",
|
||||
"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",
|
||||
"logging.file_path",
|
||||
"logging.max_size_mb",
|
||||
"logging.max_files",
|
||||
"disk.min_free_mb",
|
||||
"disk.warn_free_mb",
|
||||
"blocklist_update.enabled",
|
||||
"blocklist_update.interval_hours",
|
||||
],
|
||||
settings: {
|
||||
blocking: {
|
||||
@@ -753,51 +683,18 @@ export const sample_get_settings: SettingsEnvelope = {
|
||||
|
||||
export const sample_put_settings: SettingsEnvelope = {
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"dns.rate_limit",
|
||||
"dns.rate_window_seconds",
|
||||
"blocking.response",
|
||||
"blocking.ttl",
|
||||
"cache.size",
|
||||
"cache.negative_ttl_max",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.api_localhost_exempt",
|
||||
"web.sse_max_connections_per_ip",
|
||||
"web.trusted_proxies",
|
||||
"doh_server.enabled",
|
||||
"doh_server.bind",
|
||||
"doh_server.port",
|
||||
"doh_server.cert_path",
|
||||
"doh_server.key_path",
|
||||
"dot_server.enabled",
|
||||
"dot_server.bind",
|
||||
"dot_server.port",
|
||||
"dot_server.cert_path",
|
||||
"dot_server.key_path",
|
||||
"edns.ecs_mode",
|
||||
"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",
|
||||
"logging.file_path",
|
||||
"logging.max_size_mb",
|
||||
"logging.max_files",
|
||||
"disk.min_free_mb",
|
||||
"disk.warn_free_mb",
|
||||
"blocklist_update.enabled",
|
||||
"blocklist_update.interval_hours",
|
||||
],
|
||||
settings: {
|
||||
blocking: {
|
||||
@@ -903,31 +800,35 @@ 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: [
|
||||
export const sample_get_overview: Overview = {
|
||||
bucket_seconds: 0,
|
||||
buckets: [
|
||||
{
|
||||
count: 0,
|
||||
qtype: 0,
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
queries: 0,
|
||||
ts: 0,
|
||||
},
|
||||
],
|
||||
clients: [
|
||||
{
|
||||
count: 0,
|
||||
qtype: null,
|
||||
buckets: [0],
|
||||
client: "192.0.2.30",
|
||||
},
|
||||
{
|
||||
buckets: [0],
|
||||
client: "192.0.2.31",
|
||||
},
|
||||
{
|
||||
buckets: [0],
|
||||
client: "192.0.2.32",
|
||||
},
|
||||
],
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_stats_routes: StatsRoutes = {
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: true,
|
||||
},
|
||||
other: [0],
|
||||
period: "1h",
|
||||
routes: [
|
||||
{
|
||||
@@ -972,32 +873,22 @@ export const sample_get_stats_routes: StatsRoutes = {
|
||||
},
|
||||
],
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_stats_clients: StatsClients = {
|
||||
bucket_seconds: 0,
|
||||
clients: [
|
||||
totals: {
|
||||
avg_response_time_us: 0,
|
||||
blocked: 0,
|
||||
clients: 0,
|
||||
queries: 0,
|
||||
},
|
||||
types: [
|
||||
{
|
||||
buckets: [0],
|
||||
client: "192.0.2.30",
|
||||
count: 0,
|
||||
qtype: 0,
|
||||
},
|
||||
{
|
||||
buckets: [0],
|
||||
client: "192.0.2.31",
|
||||
},
|
||||
{
|
||||
buckets: [0],
|
||||
client: "192.0.2.32",
|
||||
count: 0,
|
||||
qtype: null,
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
available_since: 0,
|
||||
complete: true,
|
||||
},
|
||||
other: [0],
|
||||
period: "1h",
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
|
||||
@@ -21,11 +21,7 @@ import type {
|
||||
export const queryKeys = {
|
||||
health: ["health"] as const,
|
||||
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,
|
||||
overview: (period: Period) => ["overview", period] as const,
|
||||
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
|
||||
queryDetail: (id: number) => ["queries", "detail", id] as const,
|
||||
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
|
||||
@@ -54,34 +50,10 @@ export const healthQuery = () =>
|
||||
export const versionQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, staleTime: Infinity });
|
||||
|
||||
export const statsQuery = (period: Period = "24h") =>
|
||||
queryOptions({ queryKey: queryKeys.stats(period), queryFn: () => api.getStats(period), refetchInterval: 30_000 });
|
||||
|
||||
export const timeseriesQuery = (period: Period = "24h") =>
|
||||
export const overviewQuery = (period: Period = "24h") =>
|
||||
queryOptions({
|
||||
queryKey: queryKeys.timeseries(period),
|
||||
queryFn: () => api.getStatsTimeseries(period),
|
||||
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),
|
||||
queryKey: queryKeys.overview(period),
|
||||
queryFn: () => api.getOverview(period),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
@@ -328,14 +300,11 @@ export const clientPrefixesPutMutation = (qc: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
// The pool builds its clients at startup, so every upstream write leaves the
|
||||
// server owing a restart. The flag it sets lives on /api/config/status, and the
|
||||
// shell notice reads it there — hence the second invalidation.
|
||||
// An upstream write rebuilds the pool in the running process, so the row list
|
||||
// is the only thing it changes: no restart is owed and /api/config/status says
|
||||
// the same thing after the write as before it.
|
||||
function invalidateUpstreams(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.upstreams }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.configStatus }),
|
||||
]);
|
||||
return qc.invalidateQueries({ queryKey: queryKeys.upstreams });
|
||||
}
|
||||
|
||||
export const upstreamCreateMutation = (qc: QueryClient) => ({
|
||||
|
||||
+24
-39
@@ -291,15 +291,13 @@ export interface DiagnosticsFilter {
|
||||
before?: number;
|
||||
}
|
||||
|
||||
export interface StatsTotals {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
/** The window's four headline numbers. */
|
||||
export interface OverviewTotals {
|
||||
queries: number;
|
||||
blocked: number;
|
||||
/** Distinct clients seen in the window, not a sum of per-bucket counts. */
|
||||
clients: number;
|
||||
avg_response_time_us: number | null;
|
||||
coverage: Coverage;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
@@ -309,67 +307,53 @@ export interface Bucket {
|
||||
cached: number;
|
||||
}
|
||||
|
||||
export interface StatsTimeseries {
|
||||
period: Period;
|
||||
since: number;
|
||||
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/provenance/qtype.ts`), and a row whose
|
||||
* type was never recorded keeps its own `null` group rather than disappearing.
|
||||
*/
|
||||
export interface StatsTypeRow {
|
||||
export interface OverviewTypeRow {
|
||||
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 {
|
||||
export interface OverviewRouteRow {
|
||||
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 {
|
||||
/** One client's per-bucket counts, aligned to `Overview.buckets`. */
|
||||
export interface OverviewClientSeries {
|
||||
client: string;
|
||||
buckets: number[];
|
||||
}
|
||||
|
||||
export interface StatsClients {
|
||||
/**
|
||||
* Everything the Overview page draws, for one window, from one request. The
|
||||
* server answers all six panels out of a single read transaction, so the
|
||||
* headline totals, the two timelines and the two breakdowns are guaranteed to
|
||||
* describe the same span *and* the same database state — a coherence the five
|
||||
* endpoints this replaces could not offer.
|
||||
*/
|
||||
export interface Overview {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
bucket_seconds: number;
|
||||
totals: OverviewTotals;
|
||||
buckets: Bucket[];
|
||||
/** The eight busiest clients in the window, ranked by total count. */
|
||||
clients: StatsClientSeries[];
|
||||
clients: OverviewClientSeries[];
|
||||
/** Everything outside the top eight. Always present and always bucket-count-sized. */
|
||||
other: number[];
|
||||
types: OverviewTypeRow[];
|
||||
routes: OverviewRouteRow[];
|
||||
coverage: Coverage;
|
||||
}
|
||||
|
||||
@@ -550,7 +534,7 @@ export interface UpstreamEcho {
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
tls_name: string;
|
||||
restart_required: true;
|
||||
restart_required: false;
|
||||
}
|
||||
|
||||
export interface PauseState {
|
||||
@@ -646,8 +630,9 @@ export interface SettingsEnvelope {
|
||||
* authenticated route, never one of the open ones.
|
||||
*
|
||||
* `restart_pending` is true once the server has committed a change only a
|
||||
* restart applies (an upstream write, a settings key). Nothing but process
|
||||
* exit clears it, so a browser reload cannot dismiss it.
|
||||
* restart applies. Every settings key applies live except the listener binds
|
||||
* and `web.enabled`, so those are the only writes that raise it. Nothing but
|
||||
* process exit clears it, so a browser reload cannot dismiss it.
|
||||
*/
|
||||
export interface ConfigStatus {
|
||||
authority: "database" | "managed_file";
|
||||
|
||||
+17
-16
@@ -32,18 +32,15 @@ import {
|
||||
groupsQuery,
|
||||
healthQuery,
|
||||
localRecordsQuery,
|
||||
overviewQuery,
|
||||
queriesInfiniteQuery,
|
||||
queryDetailQuery,
|
||||
rulesQuery,
|
||||
settingsQuery,
|
||||
statsClientsQuery,
|
||||
statsQuery,
|
||||
statsRoutesQuery,
|
||||
statsTypesQuery,
|
||||
timeseriesQuery,
|
||||
upstreamsQuery,
|
||||
} from "@/lib/queries";
|
||||
import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period";
|
||||
import { OverviewPending } from "@/features/overview/OverviewFrame";
|
||||
import {
|
||||
validateGroupId,
|
||||
validateProtectionSearch,
|
||||
@@ -168,26 +165,28 @@ const overviewRoute = createRoute({
|
||||
}),
|
||||
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.
|
||||
* Started here, awaited nowhere. The page reads these with `useQuery` and owns
|
||||
* its own loading and error surface, so awaiting would trade that contract for
|
||||
* one blocking navigation: nothing at all until the request answered, rather
|
||||
* than the heading and the period picker while it is in flight. The rejections
|
||||
* are caught only to keep them from going unhandled; the page states 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)));
|
||||
start(context.queryClient.ensureQueryData(overviewQuery(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")),
|
||||
/**
|
||||
* The page's own loading surface, rendered while its chunk is still in flight.
|
||||
* The default pending component would put a second, differently-placed
|
||||
* "Loading…" before it, which reads as a stutter rather than one wait.
|
||||
* `OverviewFrame` is a separate module so this import leaves the charts lazy.
|
||||
*/
|
||||
pendingComponent: OverviewPending,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -425,6 +424,8 @@ export function createAppRouter(history?: RouterHistory, queryClient: QueryClien
|
||||
context: { queryClient },
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 0,
|
||||
scrollRestoration: true,
|
||||
scrollToTopSelectors: ["#main-content"],
|
||||
defaultPendingComponent: RoutePending,
|
||||
defaultErrorComponent: RouteError,
|
||||
});
|
||||
|
||||
@@ -19,44 +19,16 @@ const RECONCILED_AT = 1754899200;
|
||||
const DATABASE: ConfigStatus = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"/api/stats?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
queries: 0,
|
||||
blocked: 0,
|
||||
clients: 0,
|
||||
avg_response_time_us: null,
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
"/api/stats/timeseries?period=24h": {
|
||||
"/api/overview?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
bucket_seconds: 1800,
|
||||
totals: { queries: 0, blocked: 0, clients: 0, avg_response_time_us: null },
|
||||
buckets: [],
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
"/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 },
|
||||
},
|
||||
@@ -146,6 +118,15 @@ test("shell renders the overview route with all nav links", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("main carries the ids the router scrolls and restores", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
|
||||
const main = screen.getByRole("main");
|
||||
expect(main.id).toBe("main-content");
|
||||
expect(main.getAttribute("data-scroll-restoration-id")).toBe("main");
|
||||
});
|
||||
|
||||
test("the three configuration pages sit under a labelled group, after the rest", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
@@ -233,12 +214,45 @@ test("mount probe reveals the logout button and a failed logout surfaces inline"
|
||||
|
||||
renderShell();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
||||
// Both renderings are mounted; the viewport paints the sidebar one on WIDE
|
||||
// and the header one below it.
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Log out" })).toHaveLength(2));
|
||||
const aside = document.querySelector("aside") as HTMLElement;
|
||||
fireEvent.click(within(aside).getByRole("button", { name: "Log out" }));
|
||||
|
||||
await screen.findByText("Rate limited. Try again in 7s.");
|
||||
await within(aside).findByText("Rate limited. Try again in 7s.");
|
||||
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Log out sits in the sidebar on wide, and only in the header below it", async () => {
|
||||
stubFetch((url) =>
|
||||
url === "/api/auth/login"
|
||||
? new Response(JSON.stringify({ error: "password required" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
: null,
|
||||
);
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
|
||||
const aside = document.querySelector("aside") as HTMLElement;
|
||||
const header = document.querySelector("header") as HTMLElement;
|
||||
await waitFor(() => expect(within(aside).getByRole("button", { name: "Log out" })).toBeTruthy());
|
||||
expect(within(header).getByRole("button", { name: "Log out" })).toBeTruthy();
|
||||
// The header is the narrow rendering now: the Menu button is its only nav.
|
||||
expect(within(header).getByRole("button", { name: "Menu" })).toBeTruthy();
|
||||
|
||||
// In the sidebar the control precedes the version label rather than crowding it.
|
||||
const version = within(aside).getByText(/^nxdns v/);
|
||||
const logout = within(aside).getByRole("button", { name: "Log out" });
|
||||
expect(logout.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
// The drawer keeps no third copy: the header already carries the narrow one.
|
||||
fireEvent.click(within(header).getByRole("button", { name: "Menu" }));
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the header carries no protection display at all any more", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
@@ -276,6 +290,40 @@ test("the mobile drawer carries the same control, not a header one it lost", asy
|
||||
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the Menu button is a disclosure trigger, and it names the panel it opens", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
const menu = screen.getByRole("button", { name: "Menu" });
|
||||
|
||||
// The trigger states the drawer's state, and points at the drawer itself.
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(menu.getAttribute("aria-controls")).toBe("mobile-nav");
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||
|
||||
fireEvent.click(menu);
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(drawer.getAttribute("hidden")).toBeNull();
|
||||
expect(within(drawer).getByRole("navigation", { name: "Main" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(menu);
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("following a drawer link closes the drawer behind it", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
const menu = screen.getByRole("button", { name: "Menu" });
|
||||
fireEvent.click(menu);
|
||||
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
fireEvent.click(within(drawer).getByRole("link", { name: "Clients" }));
|
||||
|
||||
await waitFor(() => expect(menu.getAttribute("aria-expanded")).toBe("false"));
|
||||
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useId, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Disclosure, DisclosurePanel } from "react-aria-components";
|
||||
import { useAuth } from "@/auth/store";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { healthQuery, versionQuery } from "@/lib/queries";
|
||||
@@ -110,8 +111,21 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
/**
|
||||
* In a 14rem column the button and its error stack, so the button spans the
|
||||
* sidebar and matches the Pause trigger below it.
|
||||
*/
|
||||
sidebarLogout: {
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
gap: "0.25rem",
|
||||
paddingInline: "1rem",
|
||||
paddingTop: "0.75rem",
|
||||
},
|
||||
shell: {
|
||||
minHeight: "100dvh",
|
||||
height: { default: null, [WIDE]: "100dvh" },
|
||||
overflow: { default: null, [WIDE]: "hidden" },
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
display: { default: "block", [WIDE]: "grid" },
|
||||
@@ -120,6 +134,8 @@ const styles = stylex.create({
|
||||
sidebar: {
|
||||
display: { default: "none", [WIDE]: "flex" },
|
||||
flexDirection: { default: null, [WIDE]: "column" },
|
||||
minHeight: { default: null, [WIDE]: 0 },
|
||||
overflow: { default: null, [WIDE]: "hidden" },
|
||||
borderRightWidth: 1,
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: colors.border,
|
||||
@@ -133,15 +149,19 @@ const styles = stylex.create({
|
||||
},
|
||||
sidebarNav: {
|
||||
flex: 1,
|
||||
minHeight: { default: null, [WIDE]: 0 },
|
||||
overflowY: { default: null, [WIDE]: "auto" },
|
||||
paddingInline: "0.5rem",
|
||||
},
|
||||
column: {
|
||||
display: "flex",
|
||||
minHeight: "100dvh",
|
||||
minHeight: { default: "100dvh", [WIDE]: 0 },
|
||||
minWidth: { default: null, [WIDE]: 0 },
|
||||
flexDirection: "column",
|
||||
},
|
||||
/** Narrow only: on WIDE the sidebar carries everything this row held. */
|
||||
header: {
|
||||
display: "flex",
|
||||
display: { default: "flex", [WIDE]: "none" },
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
borderBottomWidth: 1,
|
||||
@@ -177,6 +197,9 @@ const styles = stylex.create({
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
minHeight: { default: null, [WIDE]: 0 },
|
||||
minWidth: { default: null, [WIDE]: 0 },
|
||||
overflowY: { default: null, [WIDE]: "auto" },
|
||||
padding: "1rem",
|
||||
},
|
||||
});
|
||||
@@ -274,13 +297,13 @@ function VersionFooter() {
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutButton() {
|
||||
function LogoutButton({ style }: { style?: stylex.StyleXStyles }) {
|
||||
const { authRequired, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
if (authRequired !== true) return null;
|
||||
return (
|
||||
<div {...stylex.props(styles.logoutRow)}>
|
||||
<div {...stylex.props(styles.logoutRow, style)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -308,37 +331,45 @@ export default function AppShell() {
|
||||
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
|
||||
<NavLinks />
|
||||
</nav>
|
||||
<LogoutButton style={styles.sidebarLogout} />
|
||||
<SidebarFooter />
|
||||
</aside>
|
||||
<div {...stylex.props(styles.column)}>
|
||||
{/* The column itself is the disclosure: the trigger sits in the header
|
||||
and the panel opens below the notices, so any wrapper around only
|
||||
the two would have to cut across the column's own flex children. */}
|
||||
<Disclosure isExpanded={drawerOpen} onExpandedChange={setDrawerOpen} {...stylex.props(styles.column)}>
|
||||
<header {...stylex.props(styles.header)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls="mobile-nav"
|
||||
onClick={() => setDrawerOpen((open) => !open)}
|
||||
{...stylex.props(shared.button, styles.narrowOnly, shared.focusRing)}
|
||||
<Button
|
||||
slot="trigger"
|
||||
className={() =>
|
||||
stylex.props(shared.button, styles.narrowOnly, shared.focusRing).className ?? ""
|
||||
}
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
</Button>
|
||||
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
|
||||
<div {...stylex.props(styles.headerRight)}>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
<ConfigStatusNotices />
|
||||
<DisclosurePanel id="mobile-nav" {...stylex.props(styles.drawer)}>
|
||||
{/* React Aria only hides a collapsed panel; the shell drops it
|
||||
instead, so a closed drawer keeps no second health poll and
|
||||
no second Pause control alive behind the header. */}
|
||||
{drawerOpen && (
|
||||
<div id="mobile-nav" {...stylex.props(styles.drawer)}>
|
||||
<>
|
||||
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
|
||||
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
||||
</nav>
|
||||
<SidebarFooter />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<main {...stylex.props(styles.main)}>
|
||||
</DisclosurePanel>
|
||||
<main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import Checkbox, { CheckboxGroup } from "./Checkbox";
|
||||
|
||||
/**
|
||||
* The drawn box, which is the label's one child that does not hold the hidden
|
||||
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
|
||||
* class list is the only place the composed ring is observable.
|
||||
*/
|
||||
function indicator(input: HTMLElement): HTMLElement {
|
||||
const label = input.closest("label") as HTMLElement;
|
||||
const spans = [...label.querySelectorAll("span")];
|
||||
const box = spans.find((span) => !span.contains(input));
|
||||
if (box === undefined) throw new Error("the checkbox drew no indicator");
|
||||
return box;
|
||||
}
|
||||
|
||||
test("a standalone checkbox reports its state and reads its label as its name", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Checkbox isSelected onChange={onChange}>
|
||||
Safe search
|
||||
</Checkbox>,
|
||||
);
|
||||
|
||||
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
|
||||
expect(box.checked).toBe(true);
|
||||
|
||||
fireEvent.click(box);
|
||||
// The caller owns the state, so the box reports the value it would move to
|
||||
// rather than moving there itself.
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("a disabled checkbox keeps its state on screen but takes no input", () => {
|
||||
render(
|
||||
<Checkbox isSelected isDisabled onChange={vi.fn()}>
|
||||
Safe search
|
||||
</Checkbox>,
|
||||
);
|
||||
|
||||
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
|
||||
expect(box.checked).toBe(true);
|
||||
// The disabled input is the whole guard: a browser fires no click on one, and
|
||||
// it is out of the tab order. Clicking it here would prove nothing either way,
|
||||
// because `fireEvent` dispatches straight at the node and skips that check.
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("keyboard focus composes the ring onto the drawn box", () => {
|
||||
render(
|
||||
<Checkbox isSelected={false} onChange={vi.fn()}>
|
||||
Ads
|
||||
</Checkbox>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("checkbox", { name: "Ads" });
|
||||
const box = indicator(input);
|
||||
const idle = box.className.split(" ");
|
||||
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
|
||||
|
||||
// React Aria only calls focus visible after the modality is keyboard, which
|
||||
// is why a bare focus() is not enough to raise the ring.
|
||||
act(() => {
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
input.focus();
|
||||
});
|
||||
|
||||
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
|
||||
const ringed = box.className.split(" ");
|
||||
// The box gained classes it did not have: the ring is composed onto it, not
|
||||
// merely reported by React Aria on the root.
|
||||
expect(ringed.length).toBeGreaterThan(idle.length);
|
||||
expect(idle.every((name) => ringed.includes(name))).toBe(true);
|
||||
});
|
||||
|
||||
test("inside a group the group owns the selection, and the group carries the name", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<CheckboxGroup aria-label="Assigned sources" value={["1"]} onChange={onChange}>
|
||||
<Checkbox value="1">Ads</Checkbox>
|
||||
<Checkbox value="2">Trackers</Checkbox>
|
||||
</CheckboxGroup>,
|
||||
);
|
||||
|
||||
const group = screen.getByRole("group", { name: "Assigned sources" });
|
||||
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(true);
|
||||
expect((within(group).getByRole("checkbox", { name: "Trackers" }) as HTMLInputElement).checked).toBe(false);
|
||||
|
||||
// The group reports the whole set, not the box that moved.
|
||||
fireEvent.click(within(group).getByRole("checkbox", { name: "Trackers" }));
|
||||
expect(onChange).toHaveBeenCalledWith(["1", "2"]);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The checkbox, wrapping React Aria's.
|
||||
*
|
||||
* React Aria hides the real input and leaves the mark to the call site, so the
|
||||
* box below is what the reader sees. It is drawn to the native control's size
|
||||
* so a row that held a native checkbox keeps its height and its baseline.
|
||||
*
|
||||
* One component covers both uses: pass `value` for a box inside a
|
||||
* `CheckboxGroup`, which owns the selection, or `isSelected`/`onChange` for a
|
||||
* standalone box that owns its own.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Checkbox as AriaCheckbox } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
/**
|
||||
* Re-exported rather than wrapped: the group adds no styling of its own, and
|
||||
* routing it through here keeps React Aria's checkbox parts to one import site,
|
||||
* the same rule `Select` follows.
|
||||
*/
|
||||
export { CheckboxGroup } from "react-aria-components";
|
||||
|
||||
interface Common {
|
||||
/** The visible label, which is also the accessible name. */
|
||||
children: ReactNode;
|
||||
/** Visible but inert; React Aria also drops it from the tab order. */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
/** Inside a `CheckboxGroup`, which holds the selection for every box in it. */
|
||||
interface Grouped extends Common {
|
||||
value: string;
|
||||
isSelected?: never;
|
||||
onChange?: never;
|
||||
}
|
||||
|
||||
/** On its own, where the caller holds the state and is told to move it. */
|
||||
interface Standalone extends Common {
|
||||
value?: never;
|
||||
isSelected: boolean;
|
||||
onChange: (isSelected: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two modes are exclusive: a grouped box that also carried `isSelected`
|
||||
* would have two sources of truth, and a standalone one without `onChange`
|
||||
* could never move. The union is what makes both unrepresentable.
|
||||
*/
|
||||
type Props = Grouped | Standalone;
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* The whole label is the hit area, so it carries the 44px pointer-target
|
||||
* floor the dialog's Close button already sets, on both axes. The text is
|
||||
* 20px tall, and a one-word source name is narrower than 44px again.
|
||||
*/
|
||||
label: {
|
||||
minHeight: 44,
|
||||
minWidth: 44,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
box: {
|
||||
flexShrink: 0,
|
||||
boxSizing: "border-box",
|
||||
width: "0.875rem",
|
||||
height: "0.875rem",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.1875rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
boxSelected: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
},
|
||||
/**
|
||||
* The shared ring keys off `:focus-visible`, which lands on the hidden input
|
||||
* rather than on this box, so the ring follows the state React Aria reports.
|
||||
*/
|
||||
boxFocused: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** The box dims, not the words: the label still has to be readable. */
|
||||
boxDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
/** `currentColor` so the mark follows the selected box's foreground token. */
|
||||
function CheckMark() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 12 12" width="10" height="10" fill="none">
|
||||
<path
|
||||
d="M2.5 6.5 5 9l4.5-5.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Checkbox({ children, ...state }: Props) {
|
||||
return (
|
||||
<AriaCheckbox {...state} className={() => stylex.props(styles.label).className ?? ""}>
|
||||
{(renderProps) => (
|
||||
<>
|
||||
<span
|
||||
{...stylex.props(
|
||||
styles.box,
|
||||
renderProps.isSelected && styles.boxSelected,
|
||||
renderProps.isFocusVisible && styles.boxFocused,
|
||||
renderProps.isDisabled && styles.boxDisabled,
|
||||
)}
|
||||
>
|
||||
{renderProps.isSelected && <CheckMark />}
|
||||
</span>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</AriaCheckbox>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* and a stray outside click is not one. Escape still cancels.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
@@ -19,6 +20,14 @@ interface Props {
|
||||
/** The full sentence the operator reads before confirming; names the entity. */
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
/**
|
||||
* Stands in for the confirm action when the write can no longer land, the way
|
||||
* an open edit dialog drops its Save. Closing the dialog instead would throw
|
||||
* focus at a control that is now disabled and say nothing about why, so the
|
||||
* question stays on screen and only the answer that would fail is withdrawn.
|
||||
* Cancel is always live.
|
||||
*/
|
||||
lock?: ReactNode;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -67,6 +76,7 @@ const styles = stylex.create({
|
||||
marginTop: "1.5rem",
|
||||
},
|
||||
dangerButton: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.danger,
|
||||
@@ -79,7 +89,7 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
|
||||
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, lock, onConfirm, onCancel }: Props) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
@@ -98,6 +108,7 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, on
|
||||
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Cancel
|
||||
</button>
|
||||
{lock === undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
@@ -105,6 +116,9 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, on
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
) : (
|
||||
lock
|
||||
)}
|
||||
</div>
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
|
||||
+83
-10
@@ -4,18 +4,25 @@
|
||||
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
|
||||
* wiring that the hand-rolled overlay only approximated. State is controlled by
|
||||
* the caller because the trigger is a table row button, not a `DialogTrigger`.
|
||||
*
|
||||
* The title is both the visible heading and the accessible name: `Heading
|
||||
* slot="title"` is what React Aria points `aria-labelledby` at, so a caller
|
||||
* cannot name the dialog one thing and show another.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
import { styles as shared } from "./styles";
|
||||
|
||||
interface Props {
|
||||
/** The dialog's accessible name. */
|
||||
label: string;
|
||||
/** The dialog's heading, and with it the dialog's accessible name. */
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** `detail` is the wider panel a record needs; `form` fits a column of fields. */
|
||||
size?: "form" | "detail";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -30,25 +37,74 @@ const styles = stylex.create({
|
||||
padding: "1rem",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
/**
|
||||
* A column so the header stays put and the body scrolls under it. The height
|
||||
* cap is what keeps a long record inside the viewport instead of running off
|
||||
* the bottom of a short one.
|
||||
*/
|
||||
panel: {
|
||||
width: "100%",
|
||||
maxWidth: "28rem",
|
||||
maxHeight: "85vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: "0.5rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
padding: "1.5rem",
|
||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
/** The panel already draws the boundary; the dialog's own ring would double it. */
|
||||
panelForm: {
|
||||
maxWidth: "28rem",
|
||||
},
|
||||
panelDetail: {
|
||||
maxWidth: "52rem",
|
||||
},
|
||||
/**
|
||||
* The panel already draws the boundary; the dialog's own ring would double
|
||||
* it. `minHeight: 0` is what lets the body shrink far enough to scroll.
|
||||
*/
|
||||
body: {
|
||||
outlineStyle: "none",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
},
|
||||
header: {
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
paddingInline: "1.5rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
title: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
/** 44px on both axes: the pointer-target floor, which the word alone misses. */
|
||||
close: {
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
content: {
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
paddingInline: "1.5rem",
|
||||
paddingBlock: "1.5rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
||||
export default function Dialog({ title, isOpen, onClose, size = "form", children }: Props) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
@@ -58,9 +114,26 @@ export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
||||
isDismissable
|
||||
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||
>
|
||||
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
||||
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
|
||||
{children}
|
||||
<Modal
|
||||
className={() =>
|
||||
stylex.props(styles.panel, size === "detail" ? styles.panelDetail : styles.panelForm).className ??
|
||||
""
|
||||
}
|
||||
>
|
||||
<AriaDialog {...stylex.props(styles.body)}>
|
||||
<div {...stylex.props(styles.header)}>
|
||||
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
|
||||
{title}
|
||||
</Heading>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
{...stylex.props(shared.button, styles.close, shared.focusRing)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div {...stylex.props(styles.content)}>{children}</div>
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import Switch from "./Switch";
|
||||
|
||||
/**
|
||||
* The drawn track, which is the label's one span that does not hold the hidden
|
||||
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
|
||||
* class list is the only place the composed state is observable.
|
||||
*/
|
||||
function track(input: HTMLElement): HTMLElement {
|
||||
const label = input.closest("label") as HTMLElement;
|
||||
const spans = [...label.querySelectorAll("span")];
|
||||
const drawn = spans.find((span) => !span.contains(input));
|
||||
if (drawn === undefined) throw new Error("the switch drew no track");
|
||||
return drawn;
|
||||
}
|
||||
|
||||
test("a switch reports the switch role, not a checkbox one", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Switch isSelected onChange={onChange}>
|
||||
Safe search
|
||||
</Switch>,
|
||||
);
|
||||
|
||||
// The role is the whole point: it tells a screen reader the setting moves now
|
||||
// rather than on some later Save.
|
||||
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
|
||||
expect(control.checked).toBe(true);
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
|
||||
fireEvent.click(control);
|
||||
// The caller owns the state, so the switch reports the value it would move to.
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("a bare switch takes its name from aria-label", () => {
|
||||
render(<Switch aria-label="udp://1.1.1.1:53 enabled" isSelected={false} onChange={vi.fn()} />);
|
||||
|
||||
const control = screen.getByRole("switch", { name: "udp://1.1.1.1:53 enabled" }) as HTMLInputElement;
|
||||
expect(control.checked).toBe(false);
|
||||
});
|
||||
|
||||
test("a disabled switch keeps its state on screen but takes no input", () => {
|
||||
render(
|
||||
<Switch isSelected isDisabled onChange={vi.fn()}>
|
||||
Safe search
|
||||
</Switch>,
|
||||
);
|
||||
|
||||
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
|
||||
expect(control.checked).toBe(true);
|
||||
// The disabled input is the whole guard: a browser fires no click on one, and
|
||||
// it is out of the tab order. Clicking it here would prove nothing either way,
|
||||
// because `fireEvent` dispatches straight at the node and skips that check.
|
||||
expect(control.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("the track is drawn from the selected state, not left to the browser", () => {
|
||||
const { rerender } = render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
|
||||
|
||||
const control = screen.getByRole("switch");
|
||||
const off = track(control).className.split(" ");
|
||||
|
||||
rerender(<Switch aria-label="Safe search" isSelected onChange={vi.fn()} />);
|
||||
const on = track(control).className.split(" ");
|
||||
|
||||
// The two states compose to different class sets, so the thumb and the fill
|
||||
// actually move rather than the input alone changing.
|
||||
expect(on).not.toEqual(off);
|
||||
});
|
||||
|
||||
test("keyboard focus composes the ring onto the drawn track", () => {
|
||||
render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
|
||||
|
||||
const control = screen.getByRole("switch");
|
||||
const drawn = track(control);
|
||||
const idle = drawn.className.split(" ");
|
||||
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
|
||||
|
||||
// React Aria only calls focus visible after the modality is keyboard, which
|
||||
// is why a bare focus() is not enough to raise the ring.
|
||||
act(() => {
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
control.focus();
|
||||
});
|
||||
|
||||
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
|
||||
const ringed = drawn.className.split(" ");
|
||||
expect(ringed.length).toBeGreaterThan(idle.length);
|
||||
expect(idle.every((name) => ringed.includes(name))).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* The on/off switch, wrapping React Aria's.
|
||||
*
|
||||
* A switch, not a checkbox: every call site flips a setting that takes effect
|
||||
* the moment it moves, with no Save between. A checkbox states what a form will
|
||||
* submit, which is a promise these controls do not make.
|
||||
*
|
||||
* React Aria hides the real input and leaves the track to the call site, so the
|
||||
* track and thumb below are what the reader sees. The caller always holds the
|
||||
* state — none of these controls owns the value it shows, because the server's
|
||||
* answer is the value.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Switch as AriaSwitch } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
interface Common {
|
||||
isSelected: boolean;
|
||||
onChange: (isSelected: boolean) => void;
|
||||
/** Visible but inert; React Aria also drops it from the tab order. */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
/** With visible words beside the track, which name it. */
|
||||
interface Labelled extends Common {
|
||||
children: ReactNode;
|
||||
"aria-label"?: never;
|
||||
}
|
||||
|
||||
/** Bare, in a table cell whose column heading cannot name a single row. */
|
||||
interface Named extends Common {
|
||||
children?: never;
|
||||
"aria-label": string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch has to be named, and exactly one of the two ways: visible words that
|
||||
* `aria-label` would then override and hide from the reader who can see them,
|
||||
* or no words and a label only the screen reader gets.
|
||||
*/
|
||||
type Props = Labelled | Named;
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* The whole label is the hit area, so it carries the 44px pointer-target
|
||||
* floor on both axes, the same floor `Checkbox` and the dialog's Close
|
||||
* button already set. The track itself is far under it.
|
||||
*/
|
||||
label: {
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** Bare switches sit in a table cell, where the row sets the rhythm. */
|
||||
track: {
|
||||
flexShrink: 0,
|
||||
boxSizing: "border-box",
|
||||
width: 28,
|
||||
height: 16,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
borderRadius: 8,
|
||||
padding: 2,
|
||||
backgroundColor: colors.borderStrong,
|
||||
},
|
||||
/**
|
||||
* The thumb moves by the box's own alignment rather than by a transform, so
|
||||
* there is no transition to withhold from a reader who asked for less motion.
|
||||
*/
|
||||
trackSelected: {
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
/** As on `Checkbox`: the ring follows the state React Aria reports, because
|
||||
* `:focus-visible` lands on the hidden input rather than on this track. */
|
||||
trackFocused: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** The track dims, not the words: the label still has to be readable. */
|
||||
trackDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
thumb: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Switch({ children, ...state }: Props) {
|
||||
return (
|
||||
<AriaSwitch {...state} className={() => stylex.props(styles.label).className ?? ""}>
|
||||
{(renderProps) => (
|
||||
<>
|
||||
<span
|
||||
{...stylex.props(
|
||||
styles.track,
|
||||
renderProps.isSelected && styles.trackSelected,
|
||||
renderProps.isFocusVisible && styles.trackFocused,
|
||||
renderProps.isDisabled && styles.trackDisabled,
|
||||
)}
|
||||
>
|
||||
<span {...stylex.props(styles.thumb)} />
|
||||
</span>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</AriaSwitch>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export const styles = stylex.create({
|
||||
},
|
||||
|
||||
button: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -70,6 +71,7 @@ export const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
smallButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -80,6 +82,7 @@ export const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
largeButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -90,6 +93,7 @@ export const styles = stylex.create({
|
||||
},
|
||||
|
||||
primaryButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
@@ -102,6 +106,7 @@ export const styles = stylex.create({
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
largePrimaryButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
@@ -113,6 +118,7 @@ export const styles = stylex.create({
|
||||
},
|
||||
|
||||
rowButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
@@ -123,6 +129,7 @@ export const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
linkButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
@@ -132,6 +139,7 @@ export const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
dangerLinkButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
@@ -142,6 +150,7 @@ export const styles = stylex.create({
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
retryButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -60,3 +60,26 @@ if (globalThis.CSS === undefined) {
|
||||
* than per call; a test that genuinely never resolves still fails, only later.
|
||||
*/
|
||||
configure({ asyncUtilTimeout: 5000 });
|
||||
|
||||
/**
|
||||
* jsdom implements no `Element.prototype.scrollTo`, and its `window.scrollTo` is
|
||||
* a stub that logs "Not implemented". The router's scroll restoration calls both
|
||||
* on every navigation, so without these the shell throws into its error boundary
|
||||
* in tests while working in a browser. jsdom has no layout, so a scroll is a
|
||||
* position assignment and nothing more.
|
||||
*/
|
||||
if (typeof Element.prototype.scrollTo !== "function") {
|
||||
Element.prototype.scrollTo = function scrollTo(...args: unknown[]) {
|
||||
const options = (typeof args[0] === "object" ? args[0] : { left: args[0], top: args[1] }) as ScrollToOptions;
|
||||
if (typeof options.top === "number") this.scrollTop = options.top;
|
||||
if (typeof options.left === "number") this.scrollLeft = options.left;
|
||||
} as typeof Element.prototype.scrollTo;
|
||||
}
|
||||
|
||||
window.scrollTo = function scrollTo(...args: unknown[]) {
|
||||
const options = (typeof args[0] === "object" ? args[0] : { left: args[0], top: args[1] }) as ScrollToOptions;
|
||||
if (typeof options.top === "number")
|
||||
Object.defineProperty(window, "scrollY", { value: options.top, configurable: true });
|
||||
if (typeof options.left === "number")
|
||||
Object.defineProperty(window, "scrollX", { value: options.left, configurable: true });
|
||||
} as typeof window.scrollTo;
|
||||
|
||||
@@ -275,7 +275,21 @@ pub fn build(b: *std.Build) void {
|
||||
// needs the operator's terminal so `git commit -S` can reach pinentry —
|
||||
// none of which a workflow supplies and all of which a Run step passes
|
||||
// through.
|
||||
// The cut's schema gate compares the querylog fingerprint of the previous
|
||||
// release against this tree's. It must read that number from the file the
|
||||
// server uses, never from a copy: a duplicated DDL or a duplicated hash
|
||||
// would let the gate pass a schema change it no longer describes. Only
|
||||
// `fingerprint` and `fingerprintOf` are referenced, both of which are
|
||||
// comptime-computable text hashing, so no SQLite symbol is pulled in and
|
||||
// the host tool needs no library.
|
||||
const querylog_schema_mod = b.createModule(.{
|
||||
.root_source_file = b.path("src/storage/querylog_schema.zig"),
|
||||
.target = b.graph.host,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const cut_tool = hostTool(b, "cut");
|
||||
cut_tool.root_module.addImport("querylog_schema", querylog_schema_mod);
|
||||
const cut_run = b.addRunArtifact(cut_tool);
|
||||
// It pushes commits and tags, so it must never be answered from the run
|
||||
// cache, and it must run at the build root whatever directory `zig build`
|
||||
@@ -298,7 +312,12 @@ pub fn build(b: *std.Build) void {
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(cut_tests).step);
|
||||
cut_tests.root_module.addImport("querylog_schema", querylog_schema_mod);
|
||||
const cut_tests_run = b.addRunArtifact(cut_tests);
|
||||
// The schema-gate round trip reads `src/storage/querylog_schema.zig` off
|
||||
// disk, so the test binary has to run at the build root.
|
||||
cut_tests_run.setCwd(b.path("."));
|
||||
test_step.dependOn(&cut_tests_run.step);
|
||||
|
||||
addDist(b, options, admin_assets, .{
|
||||
.version = version_option,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
.{
|
||||
.name = .nxdns,
|
||||
.version = "0.0.9",
|
||||
.version = "0.0.14",
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x3307b311dded1d91,
|
||||
|
||||
@@ -37,6 +37,7 @@ Descriptions of what is there. No procedures, no advice.
|
||||
- [reference/api.md](reference/api.md) — every REST route, authentication and the event stream.
|
||||
- [reference/cli.md](reference/cli.md) — the six subcommands, every flag, every exit code.
|
||||
- [reference/files-and-directories.md](reference/files-and-directories.md) — the data directory layout and file modes.
|
||||
- [reference/query-log-lifecycle.md](reference/query-log-lifecycle.md) — how `querylog.db` is versioned, migrated, backed up and, rarely, recreated.
|
||||
- [reference/performance.md](reference/performance.md) — the targets and the measured numbers.
|
||||
|
||||
## Explanation
|
||||
|
||||
@@ -27,7 +27,7 @@ Directories:
|
||||
| `src/cache/` | `dns_cache.zig`: bounded in-memory TTL cache of whole response messages, keyed by the question. The clock arrives as a parameter. |
|
||||
| `src/upstream/` | Upstream resolution: shared vocabulary and the `Client` interface (`transport.zig`), DoH client (RFC 8484), DoT client (RFC 7858), per-endpoint health and backoff (`health.zig`), and `pool.zig` — priority-ordered failover that is itself a `transport.Client`, so the handler sees one interface. |
|
||||
| `src/server/` | The serving side: UDP/TCP/DoH/DoT listeners, `handler.zig` (the whole query pipeline), `cert_store.zig` (refcounted TLS cert holder), `rate_limiter.zig`, `pause.zig`, `clients.zig` (client auto-materialisation), `local_tables.zig` (published local-answer tables), `query_sink.zig` (log and SSE fanout), `shutdown.zig` (SIGINT/SIGTERM into one `std.Io.Event`). |
|
||||
| `src/storage/` | SQLite ownership: `db.zig` is the only file that calls SQLite, `config_schema.zig` + `migrations.zig` for `config.db`, `querylog_schema.zig` (open-or-recreate), async query `logger.zig`, `retention.zig`, `disk_monitor.zig`, and one repository per table under `repositories/`. |
|
||||
| `src/storage/` | SQLite ownership: `db.zig` is the only file that calls SQLite, `config_schema.zig` + `migrations.zig` for `config.db`, `querylog_schema.zig` + `querylog_versions.zig` + `querylog_migrations.zig` for `querylog.db`, async query `logger.zig`, `retention.zig`, `disk_monitor.zig`, and one repository per table under `repositories/`. |
|
||||
| `src/config/` | The one configuration model (`model.zig`), the pure validator (`validate.zig`), `import.zig`/`export.zig` (ZON to and from `config.db`, byte-stable round trip), `loader.zig` (read/parse/validate a named file, with the shared fault mapping), `reconcile.zig` (converge the database onto a parsed config by row identity). |
|
||||
| `src/web/` | The admin HTTP layer: `server.zig` (listener), `router.zig`/`routes.zig`, one file per resource under `handlers/`, `auth.zig` (sessions), `sse.zig` (live query fanout), `static.zig` (embedded SPA), `metrics.zig` (Prometheus), `openapi.zig` (served contract), `api_limiter.zig`, `http_util.zig`. |
|
||||
| `src/platform/` | OS and TLS edges: IP address values, the `std.log` sink (`logging.zig`), `statfs.zig` (free-space query via libc), client TLS over `std.crypto.tls` (`tls_client.zig`), server TLS over vendored Mbed TLS (`tls_server.zig`). |
|
||||
@@ -115,7 +115,7 @@ Two databases with opposite contracts, in one data directory (see [reference/fil
|
||||
|
||||
Which of the file and the database is *authoritative* is chosen by the invocation, not by state: bare `nxdns run` serves the database, and `nxdns run --config FILE` makes the file authoritative and reconciles the database onto it at every start. `reconcile.zig` is that convergence, matching rows by identity and writing only differences, so runtime state — blocklist checksums, compiled snapshots, client history — survives. Why it works that way is [configuration-model.md](configuration-model.md).
|
||||
|
||||
**`querylog.db` is expendable.** It is never migrated. Its schema carries a fingerprint derived from the DDL text, and at open, a missing, corrupt, non-database, `quick_check`-failing or fingerprint-mismatched file is moved aside and recreated empty — the old file is kept under a new name rather than deleted, so an operator can still look at it. Retention deletes old rows daily and periodically rewrites the file to reclaim space.
|
||||
**`querylog.db` is the expendable one, but its history is not thrown away.** It carries a logical version in `PRAGMA user_version`, and an older supported version is migrated in place at open: one transaction, behind one `querylog.db.pre-migrate-<epoch>` backup, of which only the newest is kept. Only real damage recreates the file — missing, corrupt, not a database, or failing `quick_check` — and then the old file is kept under a new name rather than deleted, so an operator can still look at it. A healthy file this build cannot read is neither migrated nor moved aside: the startup refuses and says why, because losing months of history to a rollback is worse than a server that will not start. Retention deletes old rows daily and periodically rewrites the file to reclaim space. The whole contract is [reference/query-log-lifecycle.md](../reference/query-log-lifecycle.md).
|
||||
|
||||
The split exists so that the churn of the second database can never endanger the first. Query logs are high-volume, disposable, and the thing most likely to be corrupted by a power cut on an SD card; configuration is small, irreplaceable, and the thing an operator would have to reconstruct by hand. Giving them one file would force the careful contract onto the noisy data or the loose contract onto the valuable data.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ Which of steps 4 and 5 applies to your server depends on its authority. Under `n
|
||||
Login is `POST /api/auth/login` with a JSON body. Without a session, the API answers 401:
|
||||
|
||||
```sh
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8451/api/stats
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8451/api/overview
|
||||
```
|
||||
|
||||
```
|
||||
@@ -92,7 +92,7 @@ The cookie is named `nxdns_session` and carries `HttpOnly; SameSite=Lax; Path=/`
|
||||
|
||||
```sh
|
||||
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w '%{http_code}\n' \
|
||||
http://127.0.0.1:8451/api/stats
|
||||
http://127.0.0.1:8451/api/overview
|
||||
```
|
||||
|
||||
```
|
||||
@@ -125,13 +125,13 @@ Sessions live in memory only. A restart logs everyone out. Thirty-two concurrent
|
||||
```sh
|
||||
curl -sS -b /tmp/nxdns-lab/cookies.txt -c /tmp/nxdns-lab/cookies.txt \
|
||||
-X POST http://127.0.0.1:8451/api/auth/logout
|
||||
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w 'stats: %{http_code}\n' \
|
||||
http://127.0.0.1:8451/api/stats
|
||||
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w 'overview: %{http_code}\n' \
|
||||
http://127.0.0.1:8451/api/overview
|
||||
```
|
||||
|
||||
```
|
||||
{"authenticated":false}
|
||||
stats: 401
|
||||
overview: 401
|
||||
```
|
||||
|
||||
Logging out with a stale cookie, or with none, answers the same way. The point of logging out is to end up logged out, and that is where such a request already is.
|
||||
@@ -154,7 +154,7 @@ Changing the password ends every session, including the one that made the change
|
||||
|
||||
```sh
|
||||
curl -sS -b /tmp/nxdns-lab/c2.txt -o /dev/null -w 'old session: %{http_code}\n' \
|
||||
http://127.0.0.1:8451/api/stats
|
||||
http://127.0.0.1:8451/api/overview
|
||||
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"lab-password"}' \
|
||||
-w ' (old password)\n'
|
||||
@@ -217,14 +217,14 @@ curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
curl -sS -c /tmp/nxdns-lab/c5.txt -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"offline-password"}' \
|
||||
-w ' (new password, http %{http_code})\n'
|
||||
curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'stats: %{http_code}\n' \
|
||||
http://127.0.0.1:8451/api/stats
|
||||
curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'overview: %{http_code}\n' \
|
||||
http://127.0.0.1:8451/api/overview
|
||||
```
|
||||
|
||||
```
|
||||
{"error":"invalid password"} (old password, http 401)
|
||||
{"authenticated":true,"auth_required":true} (new password, http 200)
|
||||
stats: 200
|
||||
overview: 200
|
||||
```
|
||||
|
||||
The next export shows the new hash and a null `password` again:
|
||||
@@ -245,7 +245,7 @@ See [back up and restore](back-up-and-restore.md) for when `import` does need `-
|
||||
Authentication is off. Every route is open, and a login attempt succeeds without minting anything — there is nothing to log in to, and a session that authorises nothing would be a lie for the browser to store:
|
||||
|
||||
```sh
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8453/api/stats
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8453/api/overview
|
||||
curl -sS -X POST http://127.0.0.1:8453/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"anything"}'
|
||||
```
|
||||
|
||||
@@ -267,11 +267,11 @@ cat /etc/resolv.conf
|
||||
curl -s http://127.0.0.1:8080/metrics | grep nxdns_upstream_
|
||||
```
|
||||
|
||||
`nxdns_upstream_in_flight` against `nxdns_upstream_slots` is how much of an upstream's concurrency is in use right now, and `nxdns_upstream_queued_total` counts exchanges that had to wait for a slot (with `nxdns_upstream_queued_seconds_total` for how long they waited in total). Both queue counters are approximate — they are sampled when a query is admitted, not measured as a queue length. A `queued_total` climbing with each burst means queries are waiting on the upstream rather than failing at it, and a query that waits past `upstream.total_timeout_ms` is canceled in the queue and answered SERVFAIL.
|
||||
`nxdns_upstream_in_flight` against `nxdns_upstream_slots` is how much of an upstream's concurrency is in use right now, and `nxdns_upstream_queued_total` counts exchanges that had to wait for a slot (with `nxdns_upstream_queued_seconds_total` for how long they waited in total). Both queue counters are approximate — they are sampled when a query is admitted, not measured as a queue length. A `queued_total` climbing with each burst means queries are waiting on the upstream rather than failing at it, and a query that waits past `upstream.total_timeout_ms` is given up on in the queue and answered SERVFAIL. `nxdns_upstream_budget_exhausted_total` counts those give-ups pool-wide — queries whose own budget ran out, in the queue or mid-attempt, before any upstream answered. It carries no `url` label on purpose: running out of budget is a fact about the pool, so the exhausted attempt is never charged to an upstream's health and is never attributed in the query log, although the row can still name the last endpoint whose success or recorded failure preceded it.
|
||||
|
||||
`nxdns_upstream_reuse_recoveries_total` is the other half of the picture: it counts DoT connections that went stale between exchanges and were redialed. A few are normal — a resolver is free to close an idle connection. One per query means the connection is never being reused, and every query is paying a full TLS handshake.
|
||||
|
||||
**Fix.** Raise `upstream.total_timeout_ms` if the queue drains but drains too slowly for the budget. Otherwise the queue is telling you the upstream is slow: an upstream answering in a few milliseconds does not fill eight concurrent slots at household query rates, so sustained queueing points at the resolver you configured, and a faster one is the fix. Adding a second upstream is not: failover is strict priority, not load spreading — a query waits for a slot on the first available upstream and only reaches the next one when that upstream fails or is in backoff, so a second entry adds no concurrent capacity to the first. The per-upstream slot count is compiled, not configured, so there is no knob to widen one upstream either.
|
||||
**Fix.** Raise `upstream.total_timeout_ms` if the queue drains but drains too slowly for the budget. Otherwise the queue is telling you the upstream is slow: an upstream answering in a few milliseconds does not fill eight concurrent slots at household query rates, so sustained queueing points at the resolver you configured, and a faster one is the fix. Adding a second upstream helps only with saturation, not with latency: failover is strict priority, not load spreading. A query does take the next upstream when the first one's slots are all busy — priority orders the candidates that can be admitted right now — but once every eligible upstream is full it blocks on the highest-priority one, and it still sends one query to one upstream at a time. The per-upstream slot count is compiled, not configured, so there is no knob to widen one upstream either.
|
||||
|
||||
## The disk is filling up
|
||||
|
||||
@@ -339,3 +339,37 @@ nxdns run failed: SchemaTooNew
|
||||
```
|
||||
|
||||
**Fix.** There is no downgrade. Import the export you took before upgrading into a fresh data directory with the older binary; see [Upgrade nxdns](upgrade.md).
|
||||
|
||||
## The server refuses to start over querylog.db
|
||||
|
||||
**Symptom.** The process stops at startup naming the query log, and the error is one of four names:
|
||||
|
||||
```
|
||||
error(querylog_schema): refusing to open querylog database '/var/lib/nxdns/querylog.db': it is stamped 7, and this build supports schema versions 1 to 1 (SchemaTooNew). The file is left exactly as it is; see docs/how-to/troubleshoot.md, "The server refuses to start over querylog.db"
|
||||
nxdns run failed: SchemaTooNew
|
||||
```
|
||||
|
||||
This is a refusal, not damage. nxdns will not replace a healthy query log to get itself started, so the file is left exactly as it was — schema, rows, coverage watermark and version stamp all unchanged — and the startup fails instead. All four exit 2, the code that means an operator has to act, because none of them resolves on a retry — the shipped systemd unit's `RestartPreventExitStatus=2 64` stops the unit on the first refusal instead of restart-looping it. `systemctl status nxdns` shows the refusal. `nxdns check` does not grade the query log at all, so it will not reproduce any of these.
|
||||
|
||||
[The query-log lifecycle](../reference/query-log-lifecycle.md) is the full contract behind this page.
|
||||
|
||||
**Fixes by name.**
|
||||
|
||||
- `SchemaTooNew` — the file was stamped by a newer nxdns than the one you are running, which normally means a binary was rolled back. Put the newer release back and start it: the file is exactly as that release left it. If you mean to stay on the older release, that release cannot read this file, so restore the `querylog.db.pre-migrate-<unix-seconds>` copy the upgrade left beside it — stop the server, move `querylog.db` and its `querylog.db-wal` and `querylog.db-shm` out of the way, rename the backup to `querylog.db`, and start. Starting empty is also an option: with the server stopped, move `querylog.db` and both sidecars aside and the next start creates a fresh log.
|
||||
- `SchemaUnsupported` — the stamp is not a version this build can reach. Either the file predates 0.0.12, or it came from somewhere else, or a release since deliberately broke the schema; the changelog section for the release you are running says so when it is the third case. There is no migration path, by contract. Keep the file if the history matters — copy it somewhere and read it with the `sqlite3` shell — and if starting with an empty log is acceptable, stop the server, move `querylog.db`, `querylog.db-wal` and `querylog.db-shm` out of the data directory by hand, and start again.
|
||||
- `MigrationBackupFailed` — a migration was due and the pre-migration backup could not be written, so nothing was migrated. The line above names the destination and the reason, which is almost always a full or read-only data directory. Free space or fix the permissions and start again.
|
||||
- `MigrationFailed` — read the log line above it, because two different states wear this one name.
|
||||
|
||||
**Which `MigrationFailed` you have.** The distinction is in the line the migration logged, and it decides whether you do anything at all:
|
||||
|
||||
- Before the commit: `querylog migration 1 -> 2 failed before commit (...); the database is unchanged`. Nothing was applied. The file still carries its old version and every row, and this run's backup was deleted because the original is intact. Restarting will attempt the same migration and fail the same way, so this needs the underlying cause — the log line names it — or a report.
|
||||
- After the commit: `querylog migration 1 -> 2 COMMITTED and the database IS at version 2, but the connection could not be restored: ...; the backup '...' is kept and the next start will open the migrated file normally`. The migration DID complete. Only that one startup is refused, the pre-migration backup is kept, and the next start opens the migrated file on the ordinary current-version path. Start the server again.
|
||||
|
||||
In neither case does the server start with an empty log on its own. Recreating a query log automatically is reserved for real corruption; see [why a query log is moved aside](../reference/files-and-directories.md#why-a-query-log-is-moved-aside).
|
||||
|
||||
> Not reproduced against a running service: the four refusals are covered by the
|
||||
> test suite rather than by a hand-driven install, and the released chain has no
|
||||
> migration step in it yet, so no upgrade produces a `pre-migrate` backup today.
|
||||
> The messages above are the ones `src/storage/querylog_schema.zig` and
|
||||
> `src/storage/querylog_migrations.zig` emit, with a data directory path and
|
||||
> example version numbers filled in.
|
||||
|
||||
@@ -263,7 +263,7 @@ nxdns run failed: SchemaTooNew
|
||||
> hand and `nxdns run` was pointed at it. The two lines above are that run's
|
||||
> output.
|
||||
|
||||
That run exits 1. Recovering means importing the export you took in step 1 into a fresh data directory with the older binary.
|
||||
That run exits 2, and the shipped unit stops rather than restart-loops it. Recovering means importing the export you took in step 1 into a fresh data directory with the older binary.
|
||||
|
||||
### Rolling back from file mode
|
||||
|
||||
|
||||
+6
-10
@@ -15,7 +15,7 @@ The route table is `src/web/routes.zig`; the [Operations](#operations) table bel
|
||||
413.
|
||||
- A request whose path matches but whose method does not answers 405 with an `Allow` header. An unknown `/api` path is a JSON 404; unknown non-`/api` paths fall through to the embedded SPA (`index.html`), so client-side routing works.
|
||||
- Item routes (`{id}`) match a positive integer id only.
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones, clients and client prefixes take effect live. Upstreams and `/api/settings` are restart-required, and once one of them is written `GET /api/config/status` reports `restart_pending: true`.
|
||||
- Mutations take effect live, upstreams and `/api/settings` included: a write rebuilds or reconfigures the owner it belongs to in-process. The exceptions are the settings keys that create or destroy a socket — the DNS, web, DoH and DoT bind addresses, ports and enabled flags. Writing one of those commits the row and reports `restart_pending: true` on `GET /api/config/status`; the settings envelope lists exactly those keys under `restart_required`.
|
||||
- Every route has a policy class — `read`, `config_write` or `runtime_action` — and in file mode the `config_write` routes are refused. See [Configuration authority](#configuration-authority).
|
||||
|
||||
## Authentication
|
||||
@@ -90,7 +90,7 @@ All four keys are always present; the two nullable ones carry `null` rather than
|
||||
|
||||
The route requires a session, which is why the filesystem path is here rather than on the open `/api/version` and `/api/health`.
|
||||
|
||||
`restart_pending` is per-process state and nothing but process exit clears it. It rises when this server writes an upstream or a settings key — the changes the running process cannot apply — and it is never persisted, so a `false` read after a restart means the restart happened, not that the flag was cleared. In file mode it stays false: those writes are refused before any handler runs.
|
||||
`restart_pending` is per-process state and nothing but process exit clears it. It rises for exactly one kind of change: a settings key that creates or destroys a socket — the DNS, web, DoH and DoT bind addresses, ports and enabled flags. Every other write, upstreams included, is applied in-process and leaves the flag alone. It is never persisted, so a `false` read after a restart means the restart happened, not that the flag was cleared. In file mode it stays false: those writes are refused before any handler runs.
|
||||
|
||||
`reconciled_at` answers exactly one question: **when did this process last read the file?** Compare it against the file's mtime to spot a restart that has not happened yet. It is a hint and not a verdict, in both directions — a clock that stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make a newer file look older, and the database can change without either timestamp moving. It does not tell you whether the file and the running configuration agree; answering that would take content hashing, which nxdns deliberately does not do.
|
||||
|
||||
@@ -109,11 +109,7 @@ Auth `open` means no session is required; `session` means a valid session cookie
|
||||
| GET | `/api/queries` | session | counted | read | Query log rows for the Activity page |
|
||||
| GET | `/api/queries/{id}` | session | counted | read | One query, fully explained |
|
||||
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
|
||||
| GET | `/api/stats` | session | counted | read | Totals for a period |
|
||||
| GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period |
|
||||
| GET | `/api/stats/types` | session | counted | read | Query-type breakdown for a period |
|
||||
| GET | `/api/stats/routes` | session | counted | read | How the period's queries were answered |
|
||||
| GET | `/api/stats/clients` | session | counted | read | Per-client bucketed counts for a period |
|
||||
| GET | `/api/overview` | session | counted | read | Everything the Overview page draws, for one period |
|
||||
| GET | `/api/lookup` | session | counted | read | Explain a domain |
|
||||
| GET | `/api/diagnostics` | session | counted | read | Operational event log |
|
||||
| DELETE | `/api/diagnostics` | session | counted | runtime action | Purge every resolved event |
|
||||
@@ -173,7 +169,7 @@ Static assets are not routes. The router sends unmatched non-`/api` paths to the
|
||||
|
||||
## Settings keys
|
||||
|
||||
The envelope both operations answer with is `{settings, restart_required}`: the stored values, and the list of keys a restart applies. It says nothing about the live authority or a restart already owed — those are per-process facts, and `GET /api/config/status` is their one home.
|
||||
The envelope both operations answer with is `{settings, restart_required}`: the stored values, and the list of keys a restart applies — the bind addresses, ports and enabled flags of the four listeners, and nothing else. Every other key is applied by the PUT that changes it. It says nothing about the live authority or a restart already owed — those are per-process facts, and `GET /api/config/status` is their one home.
|
||||
|
||||
`GET /api/settings` and `PUT /api/settings` speak the `section.field` keys of [the configuration reference](configuration.md), with the values in their database spelling — notably `logging.level` is `"error"`, not `"err"`. Two keys behave differently over the API than in the file: `web.password` is write-only (accepted on a `PUT`, never returned, hashed before storage), and `web.password_hash` is neither readable nor directly writable, because a client that could install a hash could install one whose password it already knows.
|
||||
|
||||
@@ -221,6 +217,6 @@ A non-empty `rewrites.cname_target` on a query detail means the decision landed
|
||||
|
||||
### Coverage
|
||||
|
||||
Every window-bounded read — `GET /api/queries` and the five `GET /api/stats*` endpoints — answers with a `coverage` object: `available_since` is the oldest instant the query log is still complete for, and `complete` is true only when the window the request asked about starts at or after it. Retention deletes rows and advances the watermark in one transaction, so a client can tell an empty window from a pruned one instead of charting the gap as zero. A request with no lower bound at all asks about the whole of history, and is never complete.
|
||||
Every window-bounded read — `GET /api/queries` and `GET /api/overview` — answers with a `coverage` object: `available_since` is the oldest instant the query log is still complete for, and `complete` is true only when the window the request asked about starts at or after it. Retention deletes rows and advances the watermark in one transaction, so a client can tell an empty window from a pruned one instead of charting the gap as zero. A request with no lower bound at all asks about the whole of history, and is never complete.
|
||||
|
||||
Each of these responses reads its rows and its watermark inside one SQLite read transaction, so retention cannot prune between the two and hand back pre-prune rows tagged with a post-prune `available_since`. Coherence stops there: two separate requests are two separate reads, and queries logged between them can move the counts.
|
||||
Each of these responses reads its rows and its watermark inside one SQLite read transaction, so retention cannot prune between the two and hand back pre-prune rows tagged with a post-prune `available_since`. `GET /api/overview` puts every Overview panel inside that one transaction, so its totals and its four breakdowns describe one database state. Coherence stops there: two separate requests are two separate reads, and queries logged between them can move the counts.
|
||||
|
||||
@@ -214,7 +214,7 @@ Prints the usage text to stdout and exits 0. `nxdns --help` and `nxdns -h` do th
|
||||
| --- | --- |
|
||||
| 0 | Success. |
|
||||
| 1 | Runtime failure — I/O, database, out of memory. A partial diagnostic report caused by an allocation failure is a runtime failure, not a verdict on the configuration. |
|
||||
| 2 | A configuration problem the operator can fix, or a `check` that found one. |
|
||||
| 2 | A configuration problem the operator can fix, a `check` that found one, or a deliberate refusal to run that no retry will clear. |
|
||||
| 64 | Usage error — unknown command or flag, a flag without its value, a missing or extra argument. |
|
||||
|
||||
Code 2 means the same thing from every subcommand. `src/config/faults.zig` holds the one list of errors that mean "the configuration the operator supplied is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits 2 whichever command read it. The list is every error the validator raises, plus `ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and `ManagedConfigUnreadable`. In practice that covers a file with a syntax error, one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate, `password` and `password_hash` set together, and a `--config` path that is absent or unreadable.
|
||||
@@ -237,6 +237,8 @@ load one with `nxdns import <file>`, or make a file the source of truth with `nx
|
||||
|
||||
`import` exits 2 for those faults and for `DestructiveImport`. That last one is deliberately not a configuration fault — it reports what applying the file would delete, rather than anything wrong with its content — and `import` decides it for itself; the answer to it is `--allow-delete`, not an edit.
|
||||
|
||||
`run` also exits 2 on the four query-log schema refusals — `SchemaTooNew`, `SchemaUnsupported`, `MigrationFailed` and `MigrationBackupFailed` — which are in the same list. They are not a verdict on a file the operator wrote, but they share the property exit 2 exists to signal: the server is refusing on purpose, an operator has to act, and a restart will only repeat the refusal. Exit 1 would put them under the unit's `Restart=on-failure` and loop them. See [the server refuses to start over querylog.db](../how-to/troubleshoot.md#the-server-refuses-to-start-over-querylogdb).
|
||||
|
||||
`OutOfMemory` is exit 1 even when problems were recorded, because the report is then incomplete. Every other error is 1.
|
||||
|
||||
Where an exit code sends you next: [troubleshoot](../how-to/troubleshoot.md).
|
||||
|
||||
@@ -37,12 +37,14 @@ Timeouts for talking to upstream resolvers.
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `upstream.attempt_timeout_ms` | u32 | 2500 | ms | 100–120000, and not above `total_timeout_ms` | deadline on one attempt against one upstream inside the pool's failover loop (`src/upstream/pool.zig`), the whole attempt including the connect |
|
||||
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100–120000 | read deadline on conditional-forward-zone exchanges (`src/local/forward_client.zig`) |
|
||||
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100–120000 | budget for a whole conditional-forward-zone exchange (`src/local/forward_client.zig`): the UDP attempt, a TC=1 fallback and the TCP retry together |
|
||||
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100–120000 | per-query budget of the upstream pool (`src/upstream/pool.zig`): every failover attempt together, not one of them; also the `nxdns check` probe deadline |
|
||||
|
||||
The two pool budgets nest. `attempt_timeout_ms` bounds one try against one upstream; when it expires the pool records the failure and moves to the next candidate. `total_timeout_ms` bounds the whole loop, so a query against five unreachable upstreams costs the total budget once, not five attempt budgets in a row. When the total expires the in-flight attempt is canceled and the query fails with a timeout.
|
||||
|
||||
`read_timeout_ms` is unrelated to both. It bounds a different subsystem — the conditional-forward-zone client — so no cross-check relates it to the pool's budgets, and it is free to sit above either of them.
|
||||
Budget semantics. The deadline is an instant, computed once when the query enters the pool, and every blocking step spends against it — waiting for a free slot on an upstream as much as the exchange itself. An attempt therefore runs against `min(now + attempt_timeout_ms, deadline)`, which near the end of the budget is *truncated*: shorter than the configured attempt. Attribution follows from that. A truncated attempt that expires is evidence about the budget, not about the upstream, so it leaves that upstream's health and success rate untouched and the query log's `upstream` field unchanged — the row may still name the endpoint of the preceding attributable attempt, and is null only when there was none — and it increments the pool-wide `nxdns_upstream_budget_exhausted_total` metric. An attempt that expires on its full budget, or that fails outright (a refused connection, a bad answer, the peer's own timeout), is evidence about the upstream: it is recorded against that upstream's health and names it in the query log. Either way the client is answered SERVFAIL. Forward-zone queries are not affected — they have one configured resolver, so a timeout there always names it.
|
||||
|
||||
`read_timeout_ms` is unrelated to both pool budgets. It bounds a different subsystem — the conditional-forward-zone client — so no cross-check relates it to the pool's budgets, and it is free to sit above either of them. Within that subsystem it is one budget for the whole exchange: a query that goes out over UDP, comes back truncated and is retried over TCP has the two legs and the fallback share `read_timeout_ms`, never one each.
|
||||
|
||||
### dns
|
||||
|
||||
@@ -318,7 +320,7 @@ Zones resolved by a specific resolver instead of the configured upstreams, for L
|
||||
| `zone` | string | required | a valid domain name; unique |
|
||||
| `resolver` | string | required | `udp://IP:port` or `tcp://IP:port`; the host must be an IP literal and the port is mandatory |
|
||||
|
||||
The resolver host must be an IP literal because resolving the resolver's own name would be a bootstrap problem. Matching is longest suffix (`src/local/forward_zones.zig`); the exchange is UDP then TCP (`src/local/forward_client.zig`) with `upstream.read_timeout_ms` as the read deadline.
|
||||
The resolver host must be an IP literal because resolving the resolver's own name would be a bootstrap problem. Matching is longest suffix (`src/local/forward_zones.zig`); the exchange is UDP then TCP (`src/local/forward_client.zig`), with `upstream.read_timeout_ms` bounding the whole exchange rather than each leg.
|
||||
|
||||
Reverse zones are declared the same way, and one is the prerequisite for [learned client names](#learned-names):
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and `nx
|
||||
| --- | --- | --- |
|
||||
| `config.db` | The configuration database, including `web.password_hash`. The source of truth in database mode; in file mode it is the runtime substrate the file is reconciled onto (see [the configuration file](#the-configuration-file)). | 0600 |
|
||||
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 |
|
||||
| `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
|
||||
| `querylog.db` | The query log: every domain every client asked for. Missing or damaged, it is recreated empty; an older schema is migrated in place, and a schema this build cannot use refuses the startup. See [the query-log lifecycle](query-log-lifecycle.md). | 0600 |
|
||||
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
|
||||
| `querylog.db.<reason>-<unix-seconds>` | A `querylog.db` this build could not use, moved aside before an empty one was created in its place. Kept, never overwritten. `<reason>` is one of `corrupt`, `not-a-database`, `quick-check-failed` or `schema-changed`; see [why a query log is moved aside](#why-a-query-log-is-moved-aside). | Whatever the renamed file had — no chmod reaches it |
|
||||
| `querylog.db.<reason>-<unix-seconds>` | A damaged `querylog.db`, moved aside before an empty one was created in its place. Kept, never overwritten. `<reason>` is one of `corrupt`, `not-a-database` or `quick-check-failed`; see [why a query log is moved aside](#why-a-query-log-is-moved-aside). | Whatever the renamed file had — no chmod reaches it |
|
||||
| `querylog.db.<reason>-<unix-seconds>-<n>` | The same, when the plain name is taken — `<n>` counts from 1 and rises until the name is free. Two recreates within one second is the case it exists for. | The same |
|
||||
| `querylog.db.pre-migrate-<unix-seconds>` | A complete copy of `querylog.db` taken immediately before a schema migration. Exactly one survives: a successful migration deletes every other one, and a later successful start retries that cleanup. Written by `VACUUM INTO`, so it holds the committed database including anything still only in the write-ahead log, and it needs no sidecars of its own. | 0600 |
|
||||
| `querylog.db.pre-migrate-<unix-seconds>-<n>` | The same, when the plain name is taken — `<n>` counts from 2. | The same |
|
||||
| `blocklists/` | Compiled blocklist snapshots, one subdirectory of the data directory. | 0700 |
|
||||
| `blocklists/<id>.list` | Exact domains for blocklist source `<id>`, one per line, behind a header. | 0600 |
|
||||
| `blocklists/<id>.wild` | Wildcard entries for the same source. | 0600 |
|
||||
@@ -52,14 +54,15 @@ The temporaries of a source that still exists are cleaned by the refresh that ow
|
||||
|
||||
### Why a query log is moved aside
|
||||
|
||||
A `querylog.db` is moved aside when it is missing nothing but usability, and the name it is given says which of the four cases it hit:
|
||||
A `querylog.db` is moved aside only when it is genuinely damaged, and the name it is given says which of the three cases it hit:
|
||||
|
||||
| `<reason>` | What happened |
|
||||
| --- | --- |
|
||||
| `corrupt` | SQLite reported the file as damaged. |
|
||||
| `not-a-database` | The file is not a SQLite database at all. |
|
||||
| `quick-check-failed` | `PRAGMA quick_check` did not answer `ok`. |
|
||||
| `schema-changed` | Nothing is wrong with the file. Its `user_version` fingerprint does not match this build's schema, so this build cannot read it. Upgrades that touch the query-log schema produce this one, and the file they set aside is a healthy database. |
|
||||
|
||||
There is no fourth case. A healthy file carrying a schema version this build cannot use is neither migrated nor renamed: the startup refuses and the file stays where it is, which is [the query-log lifecycle](query-log-lifecycle.md). You can still find a `querylog.db.schema-changed-<unix-seconds>` in a data directory, because 0.0.13 and older produced one on any schema change — and still do, if you downgrade to one of them. This build never writes that name.
|
||||
|
||||
Only the main file is renamed — its `-wal` and `-shm` are deleted, because a stale WAL would be replayed into the fresh database. A missing `querylog.db` is created without any aside file. The rename happens inside `querylog_schema.open`, before the 0600 chmod, and that chmod names `querylog.db` and its two sidecars only — so an aside file keeps the mode the file had at rename time, which for a `querylog.db` nxdns itself created is 0600 and for one an operator put there is whatever they left it at. Nothing prunes the aside files; they accumulate until an operator removes them, and each one holds the same browsing history the live query log holds.
|
||||
|
||||
@@ -67,6 +70,10 @@ Only the main file is renamed — its `-wal` and `-shm` are deleted, because a s
|
||||
|
||||
The 0600 modes are not cosmetic. `config.db` holds the argon2id password hash and `querylog.db` holds the browsing history of every client on the LAN, so both are as sensitive as each other, and a WAL file holds the same rows as the database it belongs to. SQLite creates the main database at `0644 & ~umask`; nxdns chmods it to 0600 before enabling WAL, so the sidecars inherit 0600 rather than being created world-readable.
|
||||
|
||||
A `pre-migrate` backup holds that same browsing history, so it is chmodded 0600 the way the live file is: SQLite's `VACUUM INTO` creates it at `0644 & ~umask` and nxdns restricts it immediately afterwards. A backup it cannot restrict is a failed backup — the partial file is deleted and the startup refuses with `MigrationBackupFailed`, rather than leaving a world-readable copy behind.
|
||||
|
||||
The aside files are the exception: they get no chmod at all, and an aside keeps whatever mode it had at rename time. For a `querylog.db` nxdns itself created that is 0600; for one an operator put there it is whatever they left it at.
|
||||
|
||||
## The configuration file
|
||||
|
||||
There is no default path. `--config FILE` names the file, and without that flag no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no invocation names is inert. `/etc/nxdns/config.zon` is a convention the packaging follows, not a location nxdns probes.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# The query log's lifecycle
|
||||
|
||||
What happens to `querylog.db` when nxdns opens it: how the file is versioned, when it is migrated, when the server refuses to start over it, and the one case in which it is still replaced. Source of truth: `src/storage/querylog_versions.zig` (the version metadata), `src/storage/querylog_schema.zig` (the open path) and `src/storage/querylog_migrations.zig` (the migration runner).
|
||||
|
||||
The rule this page exists to state: **a healthy `querylog.db` is never replaced and never moved aside.** A schema this build cannot use refuses the startup instead. Your query history is not the server's to discard.
|
||||
|
||||
## The version stamp
|
||||
|
||||
Every `querylog.db` carries a logical schema version in SQLite's `PRAGMA user_version`. It is a small counter — 1 in this release — and not a hash of anything. A file created by this build is stamped as it is created.
|
||||
|
||||
Two other values matter, both in `querylog_versions.zig`:
|
||||
|
||||
| Constant | Today | What it means |
|
||||
| --- | --- | --- |
|
||||
| `current_version` | 1 | The version this build creates and reads. |
|
||||
| `minimum_supported_version` | 1 | The oldest stamped version this build can migrate up to `current_version`. |
|
||||
| `legacy_fingerprint` | 1975011655 | The `user_version` the 0.0.12 and 0.0.13 binaries wrote: a CRC32 of their schema text, under the older policy where a mismatch meant "replace the file". |
|
||||
|
||||
`legacy_fingerprint` is frozen forever. Those two releases stamped a hash rather than a version, so this build recognises that one literal number as "version 1" and restamps the file as 1 on the first open. The restamp runs in its own transaction; if it fails, the old stamp and every row stay exactly as they were and the startup refuses.
|
||||
|
||||
## What an open does
|
||||
|
||||
nxdns opens `querylog.db` once at startup, before it serves anything, and no second process shares a data directory. On a file that is readable and passes `PRAGMA quick_check`, the stamp decides:
|
||||
|
||||
| Stamp | What happens |
|
||||
| --- | --- |
|
||||
| `current_version` | Opens. Nothing is migrated. |
|
||||
| `legacy_fingerprint` | Read as version 1: restamped to 1, then treated as version 1 by the rows above and below. |
|
||||
| Between `minimum_supported_version` and `current_version` | Migrated in place, then opens. |
|
||||
| Above `current_version`, up to 1000000 | REFUSE: `SchemaTooNew`. |
|
||||
| Anything else — 0, a negative, another fingerprint, a version below the minimum | REFUSE: `SchemaUnsupported`. |
|
||||
|
||||
A refusal changes nothing. The schema, the rows, the coverage watermark and the stamp are all left as they are, no file is set aside, no new file is created, and `nxdns run` exits. The log line names the path, the stamp it found, the range this build supports and [the troubleshooting section](../how-to/troubleshoot.md#the-server-refuses-to-start-over-querylogdb).
|
||||
|
||||
## Migrating in place
|
||||
|
||||
A migration is one backup and one transaction.
|
||||
|
||||
1. **Back up.** `VACUUM INTO` writes a complete copy — including anything still only in the write-ahead log — to `querylog.db.pre-migrate-<unix-seconds>` beside the database. If that name is taken, `-2`, `-3` and so on are tried. A backup that cannot be written is `MigrationBackupFailed`, and the partial copy is deleted; an older backup beside it survives.
|
||||
2. **Migrate.** `BEGIN IMMEDIATE`, re-read the stamp under the lock, run every step, run `PRAGMA foreign_key_check`, stamp the new version, `COMMIT`. One transaction covers the whole chain, so the file is either at the old version or at the new one and never in between.
|
||||
3. **Clean up.** Every other `querylog.db.pre-migrate-*` beside the file is deleted. **One backup is kept**: the one this migration just took. A later successful start retries that cleanup if it failed.
|
||||
|
||||
If a step fails before the commit, the transaction rolls back, this run's backup is deleted, and the startup refuses with `MigrationFailed`. The database keeps the version and the rows it had.
|
||||
|
||||
If the commit succeeds and something after it fails, the log says so plainly — the migration DID complete and the file IS at the new version. The backup is kept, the startup still refuses with `MigrationFailed`, and the next start opens the migrated file normally.
|
||||
|
||||
## Corruption is the only automatic recreate
|
||||
|
||||
Four conditions still create a fresh, empty `querylog.db`: the file is missing, SQLite reports it as corrupt, it is not a SQLite database at all, or `PRAGMA quick_check` does not answer `ok`. Except for the missing case, the unusable file is renamed to `querylog.db.<reason>-<unix-seconds>` and kept. See [why a query log is moved aside](files-and-directories.md#why-a-query-log-is-moved-aside).
|
||||
|
||||
Every other failure — a lock held elsewhere, a permission problem, a full disk, a version this build cannot reach — propagates and leaves the file alone.
|
||||
|
||||
## Downgrading
|
||||
|
||||
**Downgrading to 0.0.13 or older resets your query log.** Those binaries predate this contract: they compare `user_version` against a hash of their own schema text, find this build's version stamp instead, and treat that as a mismatch — so they rename `querylog.db` to `querylog.db.schema-changed-<unix-seconds>` and start an empty log. Nothing is destroyed, but the live log is empty until you put the aside file back, and the restamp that provoked it takes no backup of its own.
|
||||
|
||||
To recover, go back to a migration-aware release, stop the server, then, in the data directory:
|
||||
|
||||
1. Move the empty `querylog.db` the old binary created out of the way.
|
||||
2. Delete its `querylog.db-wal` and `querylog.db-shm`. This is not optional: replaying the empty file's write-ahead log into the restored history would corrupt it.
|
||||
3. Rename `querylog.db.schema-changed-<unix-seconds>` back to `querylog.db`.
|
||||
4. Start the server.
|
||||
|
||||
Downgrading between two migration-aware releases is safe in the sense that matters: a build that finds a stamp above its own `current_version` refuses to start with `SchemaTooNew` and touches nothing. Go forward again, or restore the `pre-migrate` backup the upgrade left.
|
||||
|
||||
## Breaking the schema on purpose
|
||||
|
||||
A release may still break the query-log schema outright rather than migrate it. That is allowed, and it is never silent. Such a release raises `current_version`, sets `minimum_supported_version` to the same value, and ships no migration step — so files from before the break classify as below the minimum and `open` refuses them with `SchemaUnsupported` rather than replacing them. The release notes carry the phrase `resets your query history` and a `Restoring your query history` section, and the cut gate refuses to build the release without both.
|
||||
|
||||
So the contract is: a break is always versioned, always refused at startup with the file intact, and always disclosed in the changelog.
|
||||
@@ -0,0 +1,21 @@
|
||||
(MIT)
|
||||
|
||||
Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018 Jed Watson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,25 @@
|
||||
The nine ISC-licensed d3 modules the admin UI bundles carry the same permission
|
||||
notice under different copyright years. The notices are reproduced together
|
||||
here, one line per package, followed by the ISC text they share.
|
||||
|
||||
d3-array Copyright 2010-2022 Mike Bostock
|
||||
d3-color Copyright 2010-2022 Mike Bostock
|
||||
d3-format Copyright 2010-2021 Mike Bostock
|
||||
d3-interpolate Copyright 2010-2021 Mike Bostock
|
||||
d3-path Copyright 2015-2022 Mike Bostock
|
||||
d3-scale Copyright 2010-2021 Mike Bostock
|
||||
d3-shape Copyright 2010-2022 Mike Bostock
|
||||
d3-time Copyright 2010-2022 Mike Bostock
|
||||
internmap Copyright 2021 Mike Bostock
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||
with or without fee is hereby granted, provided that the above copyright notice
|
||||
and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
THIS SOFTWARE.
|
||||
@@ -52,20 +52,67 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
|
||||
@tanstack/react-store 0.9.3 MIT
|
||||
@tanstack/router-core 1.171.15 MIT
|
||||
@tanstack/store 0.9.3 MIT
|
||||
@types/d3-array 3.0.3 MIT
|
||||
@types/d3-color 3.1.0 MIT
|
||||
@types/d3-delaunay 6.0.1 MIT
|
||||
@types/d3-format 3.0.1 MIT
|
||||
@types/d3-geo 3.1.0 MIT
|
||||
@types/d3-interpolate 3.0.1 MIT
|
||||
@types/d3-path 3.1.1 MIT
|
||||
@types/d3-scale 4.0.2 MIT
|
||||
@types/d3-shape 3.1.7 MIT
|
||||
@types/d3-time 3.0.0 MIT
|
||||
@types/d3-time-format 2.1.0 MIT
|
||||
@types/geojson 7946.0.16 MIT
|
||||
@types/react 19.2.17 MIT
|
||||
@types/react-dom 19.2.3 MIT
|
||||
@visx/axis 4.0.0 MIT
|
||||
@visx/bounds 4.0.0 MIT
|
||||
@visx/curve 4.0.0 MIT
|
||||
@visx/grid 4.0.0 MIT
|
||||
@visx/group 4.0.0 MIT
|
||||
@visx/point 4.0.0 MIT
|
||||
@visx/scale 4.0.0 MIT
|
||||
@visx/shape 4.0.0 MIT
|
||||
@visx/text 4.0.0 MIT
|
||||
@visx/tooltip 4.0.0 MIT
|
||||
@visx/vendor 4.0.0 MIT and ISC
|
||||
aria-hidden 1.2.6 MIT
|
||||
balanced-match 0.4.2 MIT
|
||||
classnames 2.5.1 MIT
|
||||
client-only 0.0.1 MIT
|
||||
clsx 2.1.1 MIT
|
||||
cookie-es 3.1.1 MIT
|
||||
css-mediaquery 0.1.2 BSD
|
||||
csstype 3.2.3 MIT
|
||||
d3-array 3.2.1 ISC
|
||||
d3-color 3.1.0 ISC
|
||||
d3-delaunay 6.0.2 ISC
|
||||
d3-format 3.1.0 ISC
|
||||
d3-geo 3.1.0 ISC
|
||||
d3-interpolate 3.0.1 ISC
|
||||
d3-path 3.1.0 ISC
|
||||
d3-scale 4.0.2 ISC
|
||||
d3-shape 3.2.0 ISC
|
||||
d3-time 3.1.0 ISC
|
||||
d3-time-format 4.1.0 ISC
|
||||
delaunator 5.1.0 ISC
|
||||
internmap 2.0.3 ISC
|
||||
invariant 2.2.4 MIT
|
||||
isbot 5.2.1 Unlicense
|
||||
js-tokens 4.0.0 MIT
|
||||
loose-envify 1.4.0 MIT
|
||||
math-expression-evaluator 1.4.0 MIT
|
||||
react 19.2.8 MIT
|
||||
react-aria 3.51.0 Apache-2.0
|
||||
react-aria-components 1.20.0 Apache-2.0
|
||||
react-dom 19.2.8 MIT
|
||||
react-stately 3.49.0 Apache-2.0
|
||||
react-use-measure 2.1.7 MIT
|
||||
reduce-css-calc 1.3.0 MIT
|
||||
reduce-function-call 1.0.3 MIT
|
||||
balanced-match 1.0.2 MIT
|
||||
robust-predicates 3.0.3 Unlicense
|
||||
scheduler 0.27.0 MIT
|
||||
seroval 1.5.6 MIT
|
||||
seroval-plugins 1.5.6 MIT
|
||||
@@ -90,11 +137,34 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
|
||||
@tanstack/react-store
|
||||
@tanstack/router-core
|
||||
@tanstack/store
|
||||
@visx/axis
|
||||
@visx/bounds
|
||||
@visx/grid
|
||||
@visx/group
|
||||
@visx/point
|
||||
@visx/scale
|
||||
@visx/shape
|
||||
@visx/text
|
||||
@visx/tooltip
|
||||
balanced-match
|
||||
classnames
|
||||
clsx
|
||||
d3-array
|
||||
d3-color
|
||||
d3-format
|
||||
d3-interpolate
|
||||
d3-path
|
||||
d3-scale
|
||||
d3-shape
|
||||
d3-time
|
||||
internmap
|
||||
math-expression-evaluator
|
||||
react
|
||||
react-aria
|
||||
react-aria-components
|
||||
react-dom
|
||||
react-stately
|
||||
reduce-css-calc
|
||||
reduce-function-call
|
||||
scheduler
|
||||
use-sync-external-store
|
||||
|
||||
@@ -71,6 +71,25 @@
|
||||
// a `sources` list — no guard would raise it, which is why it is written down
|
||||
// here.
|
||||
//
|
||||
// Twenty-three more joined the not-shipped list with visx, and the sourcemap
|
||||
// build settled which. Fourteen are types only and emit no runtime code at all:
|
||||
// @types/d3-array, @types/d3-color, @types/d3-delaunay, @types/d3-format,
|
||||
// @types/d3-geo, @types/d3-interpolate, @types/d3-path, @types/d3-scale,
|
||||
// @types/d3-shape, @types/d3-time, @types/d3-time-format, @types/geojson,
|
||||
// @types/react and @types/react-dom, with csstype under them. They are in the
|
||||
// runtime closure rather than among the devDependencies only because the @visx
|
||||
// packages declare them as ordinary dependencies. @visx/curve is the curve
|
||||
// factory the line and area shapes take, which these charts do not draw.
|
||||
// @visx/vendor is the one worth stating plainly: it is a re-export shim over the
|
||||
// d3 modules, npm records it as `MIT and ISC` because of what it re-exports, and
|
||||
// the bundler resolves straight through it to the d3 packages themselves — so
|
||||
// its own bytes never reach admin/dist and the d3 entry below is what covers the
|
||||
// code that does. d3-delaunay, d3-geo, d3-time-format, delaunator and
|
||||
// robust-predicates are the map and Voronoi half of that shim, which no chart
|
||||
// here imports; the first four are ISC and robust-predicates is Unlicense.
|
||||
// react-use-measure is the resize hook @visx/responsive uses, and this app keeps
|
||||
// its own, so nothing pulls it in.
|
||||
//
|
||||
// Of the remaining devDependencies, none puts a byte in admin/dist: @vitejs/
|
||||
// plugin-react and typescript only transform our own sources (react-refresh is
|
||||
// dev-server only, and tslib is optional and unused), lightningcss only
|
||||
@@ -155,6 +174,48 @@
|
||||
.note = "Bundled into the admin UI JavaScript. Separate entry from the other TanStack packages: same MIT text, different copyright line.",
|
||||
.file = "tanstack-store-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "visx (@visx/axis, @visx/bounds, @visx/grid, @visx/group, @visx/point, @visx/scale, @visx/shape, @visx/text, @visx/tooltip)",
|
||||
.version = "@visx/axis 4.0.0, @visx/bounds 4.0.0, @visx/grid 4.0.0, @visx/group 4.0.0, @visx/point 4.0.0, @visx/scale 4.0.0, @visx/shape 4.0.0, @visx/text 4.0.0, @visx/tooltip 4.0.0",
|
||||
.note = "The chart primitives the Overview charts are drawn with — scales, stacked bars, pie arcs, axes, grid and tooltip — bundled into the admin UI JavaScript. Nine of the eleven @visx packages in the closure ship; @visx/curve and @visx/vendor do not (see the note at the head of this file). All nine carry a byte-identical MIT text. In the tarballs and in the image.",
|
||||
.file = "visx-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "d3 (d3-array, d3-color, d3-format, d3-interpolate, d3-path, d3-scale, d3-shape, d3-time, internmap)",
|
||||
.version = "d3-array 3.2.1, d3-color 3.1.0, d3-format 3.1.0, d3-interpolate 3.0.1, d3-path 3.1.0, d3-scale 4.0.2, d3-shape 3.2.0, d3-time 3.1.0, internmap 2.0.3",
|
||||
.note = "The scale, colour, number-format and path arithmetic behind visx, bundled into the admin UI JavaScript. They arrive through @visx/vendor, which re-exports them without contributing bytes of its own, so these nine are what the bundle actually carries. The first ISC dependencies this project has taken: Mokhtar Mial accepted ISC inbound for nxdns on 2026-08-24, which is the decision that let them ship. ISC asks only that the copyright notice and the permission notice travel with the copies; the nine notices differ in their copyright years alone, so the file below reproduces every notice above the single shared permission text. In the tarballs and in the image.",
|
||||
.file = "d3-isc.txt",
|
||||
},
|
||||
.{
|
||||
.component = "classnames",
|
||||
.version = "classnames 2.5.1",
|
||||
.note = "The class-name joiner visx uses to merge its own chart class names with a caller's. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
|
||||
.file = "classnames-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "balanced-match",
|
||||
.version = "balanced-match 0.4.2, balanced-match 1.0.2",
|
||||
.note = "The bracket matcher reduce-function-call calls when it takes a CSS calc() expression apart. Bundled into the admin UI JavaScript. Two versions are installed, one under reduce-function-call and one at the root; both carry this text. Separate entry from the other MIT packages: same MIT text, different copyright line.",
|
||||
.file = "balanced-match-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "math-expression-evaluator",
|
||||
.version = "math-expression-evaluator 1.4.0",
|
||||
.note = "The arithmetic evaluator reduce-css-calc reduces a calc() expression with, reached from @visx/text's width measurement. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
|
||||
.file = "math-expression-evaluator-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "reduce-css-calc",
|
||||
.version = "reduce-css-calc 1.3.0",
|
||||
.note = "Resolves CSS calc() expressions for @visx/text. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
|
||||
.file = "reduce-css-calc-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "reduce-function-call",
|
||||
.version = "reduce-function-call 1.0.3",
|
||||
.note = "The function-call walker reduce-css-calc is built on. Bundled into the admin UI JavaScript. Separate entry from reduce-css-calc: same MIT text, different copyright line.",
|
||||
.file = "reduce-function-call-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "Vite",
|
||||
.version = "vite 8.1.5",
|
||||
|
||||
@@ -55,6 +55,13 @@ pub const texts: []const Text = &.{
|
||||
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
|
||||
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
|
||||
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
|
||||
.{ .name = "visx-mit.txt", .body = @embedFile("visx-mit.txt") },
|
||||
.{ .name = "d3-isc.txt", .body = @embedFile("d3-isc.txt") },
|
||||
.{ .name = "classnames-mit.txt", .body = @embedFile("classnames-mit.txt") },
|
||||
.{ .name = "balanced-match-mit.txt", .body = @embedFile("balanced-match-mit.txt") },
|
||||
.{ .name = "math-expression-evaluator-mit.txt", .body = @embedFile("math-expression-evaluator-mit.txt") },
|
||||
.{ .name = "reduce-css-calc-mit.txt", .body = @embedFile("reduce-css-calc-mit.txt") },
|
||||
.{ .name = "reduce-function-call-mit.txt", .body = @embedFile("reduce-function-call-mit.txt") },
|
||||
.{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") },
|
||||
.{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-mit.txt") },
|
||||
.{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") },
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Ankit G.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Maxime Thirouin & Joakim Bengtson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Maxime Thirouin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2018 Harrison Shoff
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,264 @@
|
||||
# Milestone 34: hot-apply — tiers A and B
|
||||
|
||||
DB-mode config changes apply live, in-process, for every key that does not create a socket. The comptime "every key is restart-required" blanket is replaced by an explicit per-key apply table. Design: rev 3 of the hot-reload analysis; spec hardened through two Codex review rounds (thread 01a02efc). Listener rebind and `web.enabled` lifecycle are milestone 35.
|
||||
|
||||
## The write contract (every session honors it)
|
||||
|
||||
Prepare → commit → publish → retire:
|
||||
1. **Prepare**: under the serialized config-mutation path, build and validate everything the change needs, from the FINAL MERGED config — ONE candidate per affected owner, never one per key. Prepared resources are heap-stable and owned (a candidate outlives the request arena). Nothing is published. Any failure: clean up every prepared resource, error to the client, NO db write.
|
||||
2. **Commit**: the DB transaction, only after every prepare succeeded. Commit failure: clean up all prepared resources.
|
||||
3. **Publish**: infallible, I/O-free operations — handle swaps, pointer swaps, stores under a lock. Closing files, joining tasks, freeing memory belong to retire, not publish.
|
||||
4. **Retire**: old generations/handles are drained, closed, and freed after their readers release.
|
||||
|
||||
Signatures: every operation that touches a `std.Io` primitive (Mutex, RwLock, Condition, concurrent, sleep) takes `io: std.Io`. The spec's named signatures include it; a builder adds it wherever else the primitive demands it.
|
||||
|
||||
## Sessions
|
||||
|
||||
Six sessions, strictly sequential (S1 → … → S6); each session owns the tree while it runs and ends with `zig build test` green (S6 adds the admin suite).
|
||||
|
||||
**Honesty rule**: `restart_pending`/`restart_required` keep firing exactly as today until S5 swaps the mechanism atomically. No earlier session removes a restart signal.
|
||||
|
||||
---
|
||||
|
||||
## Session S1: per-query policy snapshots (tier A)
|
||||
|
||||
### S1.1 DNS policy snapshot
|
||||
`src/server/handler.zig`: the per-query reads — blocking response + ttl, `ecs_mode`, `forward_read_timeout`, `negative_ttl_max` — move into one `Policy` struct behind `std.Io.RwLock` (the filter discipline, manager.zig:438/856). Each query copies the `Policy` ONCE at query start (shared lock only for the copy); `ecs_mode` at :782/:829/:835 reads the copy. Publish: `pub fn setPolicy(h: *Handler, io: std.Io, p: Policy)`.
|
||||
|
||||
### S1.2 Web trusted proxies
|
||||
`src/web/server.zig` ~:531: `trusted_proxies` becomes an owned immutable generation in `WebState`, pointer-replaced under an RwLock (io-threaded), old generation retired after readers release. Prepare copies out of the request arena.
|
||||
|
||||
### S1.3 Logger config split
|
||||
`src/storage/logger.zig`: `hide_domains` + `hide_client_ips` (applied on the PRODUCER path ~:449) are ONE privacy policy: both pack into a single atomic (one u8), stored together by `setPrivacy` and loaded ONCE per entry — a producer can never observe a mixed policy that redacts the domain but exposes the client, or vice versa. `query_log_flush_interval_s` (~:609) is an independent `.monotonic` atomic with `setFlushInterval`.
|
||||
|
||||
### S1.4 Disk thresholds
|
||||
`src/storage/disk_monitor.zig`: `min_free_mb`/`warn_free_mb` are one invariant pair (warn ≥ min): both u32s packed into ONE atomic u64; readers unpack a single load.
|
||||
|
||||
### S1 implementation notes (post-build)
|
||||
- `Context`'s provenance method `policy(...)` renamed `notePolicy` (collision with the new per-query field).
|
||||
- Pure-atomic setters (`setPrivacy`, `setFlushInterval`, `setThresholds`) take no `io` — they touch no Io primitive.
|
||||
- `LiveProxies.install` RETURNS the retired generation; the caller frees it in retire.
|
||||
- The trusted-proxies read hold ends at the client-address verdict, BEFORE router dispatch — a hold spanning dispatch would let a settings PUT deadlock on the shared lock its own request holds.
|
||||
- The flush-interval test covers short→long only; long→short is unobservable without waking a writer already parked on its old deadline (S5's PUT test inherits this bound).
|
||||
- S1 setters have no production caller until S5 wires the PUT flow — test-only until then, by design.
|
||||
|
||||
### S1.5 Acceptance criteria
|
||||
- [ ] One query is internally consistent across a concurrent `setPolicy` (both ecs/blocking reads agree).
|
||||
- [ ] trusted_proxies replaced under concurrent request-path reads; testing allocator clean.
|
||||
- [ ] Privacy flip with FORCED producer interleaving: pre-flip entries unredacted, post-flip redacted.
|
||||
- [ ] Threshold reader vs concurrent pair-stores: every observed pair satisfies warn ≥ min.
|
||||
- [ ] Flush-interval change observed on the writer's next cycle via the existing timing seam.
|
||||
- [ ] `zig build test` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S2: upstream extraction + generation owner + lock hygiene
|
||||
|
||||
### S2.1 Extract the upstream composition out of app.zig
|
||||
`Upstreams`, `build`, `deinit`, and what they need from `ConfigLoad` (all private in app.zig ~:1158) move to `src/upstream/owner.zig`; app.zig imports it, never the reverse. `build` returns the generation plus a `BuildReport`. `ConfigLoad.note` is NOT just log output — it writes operational-event rows and retains canonical keys for `finalize` (app.zig:367). Contract: at BOOT, app.zig replays the successful `BuildReport` through the exact `ConfigLoad.note` path before `finalize`, so diagnostics are unchanged; at RUNTIME, candidate preparation is side-effect-free (no event rows) until commit, and the report is RECONCILED in RETIRE (after the pointer publish — event rows are SQLite I/O, banned from publish): retire reconciles SCOPED, not via `ConfigLoad.finalize` (finalize calls `Store.resolveExcept`, events.zig:402, which resolves EVERY active configuration.load event outside its kept set — at runtime that would falsely resolve unrelated boot warnings). Each generation OWNS its report keys (stored in the generation), and the retire payload is STABLE: prepare copies the LIVE generation's report keys (under the owner mutex) into the PUT-owned apply state, so reconciliation never depends on the old generation's lifetime and NEVER runs from a query's `release` path — it runs on the PUT task in retire. Retire: emit the new report's events, then individually resolve exactly `copied_previous_keys − new_report_keys`. S5 tests through the real PUT path: introducing a malformed upstream raises the warning; fixing it resolves it; an UNRELATED active configuration warning survives the whole introduce/fix cycle.
|
||||
|
||||
### S2.2 UpstreamOwner
|
||||
Discipline: CertStore's (cert_store.zig:199/:237) — a mutex held briefly for refcounted borrows.
|
||||
- `pub fn acquire(o: *Owner, io: std.Io) *Generation` — lock, ++refs, return.
|
||||
- `pub fn release(o: *Owner, io: std.Io, g: *Generation)` — lock, --refs; a release that drops a RETIRED generation to zero deinits it (Upstreams.deinit needs io).
|
||||
- `pub fn replace(o: *Owner, io: std.Io, prepared: *Generation) ?*Generation` — lock, swap the live pointer, mark old retired; if the old generation's refs are ALREADY zero, return it for the caller to retire immediately (the CertStore refs==0 pattern) — otherwise null and the last release retires it.
|
||||
- `pub fn deinit(o: *Owner, io: std.Io)` — shutdown teardown of the live generation, called by serve() after listeners and metrics readers have stopped.
|
||||
- Prepared generations are heap-allocated and OWN every configuration string (URL text — transport.Endpoint borrows it, transport.zig:51 — plus tls_name, host, path): each generation carries its own arena covering every borrowed row string until retirement. Candidates built from request-arena rows copy into that arena at prepare.
|
||||
- Timeouts are baked into the generation; a timeout change is a replace.
|
||||
- Query path: `Handler` stores `*Owner` (replacing the boot-time type-erased client, handler.zig:91). Per exchange: acquire → generation's `transport.Client` → exchange → copy the resolver identity from `selected` (borrowed from the endpoint, transport.zig:350) into the PER-QUERY `Context.upstream_buf` (handler.zig:453 — never a Handler-owned buffer; Handler is shared across concurrent queries) → release.
|
||||
- Metrics/health: `WebState` stores `*Owner` (replacing `*Pool`, web/server.zig:144); a scrape acquires for its duration.
|
||||
|
||||
### S2.3 Cache and rate-limiter lock hygiene
|
||||
- handler.zig:799 and app.zig:1124: cache pointer loads move inside `cache_mutex`; then `replaceCache` swaps under it (entries lost — accepted).
|
||||
- handler.zig:189: same for the rate limiter; `replaceRateLimiter` under its lock (windows reset — accepted).
|
||||
|
||||
### S2 implementation notes (post-build)
|
||||
- The retire-time scoped reconciler is DEFERRED TO S5 (S2 has no runtime replace caller; uncalled code fails the values). S2 delivers its precondition: generations own copyable report keys. **S5 must implement the reconciler + its tests (including unrelated-warning-survives).**
|
||||
- `Generation` gains a test-only `borrowing`/`borrowingPool` payload so ~90 existing test sites keep their fake clients without heap generations; production always takes the built path.
|
||||
- One skipped-upstream log string reworded to unify with the event detail; the event detail (what the criterion asserts) is byte-identical.
|
||||
- Integration-gated call sites were mechanically touched (comptime-dead without -Dintegration; would have broken that build).
|
||||
|
||||
### S2.4 Acceptance criteria
|
||||
- [ ] Concurrent exchanges vs `replace`: in-flight completes on G1; G1 deinits only after last release (or immediately when refs==0 at replace); new exchanges on G2; allocator clean.
|
||||
- [ ] A replace while NO reader holds G1 retires G1 via the replace return path (refs==0 branch covered).
|
||||
- [ ] Generation string ownership: build a candidate from a transient arena, free the arena, exchange still reads valid url/tls_name (allocator-poisoning test).
|
||||
- [ ] Metrics scrape concurrent with replace: clean.
|
||||
- [ ] Cache and rate-limiter swaps under concurrent use: clean.
|
||||
- [ ] Boot diagnostics unchanged: the events ConfigLoad.note wrote before the extraction are written identically (assert on the events store, not stdout).
|
||||
- [ ] Restart signals untouched this session.
|
||||
- [ ] `zig build test` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S3: in-place reconfigure, retention, certs, sink, scheduler
|
||||
|
||||
### S3.1 Sessions TTL
|
||||
Slots gain `issued_at` beside `expires_at` (auth.zig:311). `setTtl(io, ttl)` recomputes each live slot: `expires_at = issued_at + ttl` under the existing mutex. Semantics are SERVER-SIDE: shortening takes effect for every session; lengthening is bounded by the browser cookie's original Max-Age (the cookie is not refreshed — sessions do not become sliding; state this in a comment). Login cookie Max-Age (handlers/auth.zig:127) reads the LIVE ttl.
|
||||
|
||||
### S3.2 ApiLimiter
|
||||
ALL config reads move under the existing mutex (`check` reads `localhost_exempt` pre-lock, api_limiter.zig:135). `setLimits(io, now, limits)`: refill each bucket THROUGH `now` at the old rate, clamp tokens to the new capacity, then install the new rate — no retroactive refill at the new rate, and never move a bucket's clock backward (a bucket already newer than `now` is clamped only). SSE per-IP counts untouched.
|
||||
|
||||
### S3.3 Retention days
|
||||
Two consumers (retention.zig:103, clients.zig:228): one shared `.monotonic` atomic u32 owned by app-level state, read per pass by both; `setRetentionDays` stores.
|
||||
|
||||
### S3.4 Certificate paths
|
||||
`doh_server.cert_path/key_path`, `dot_server.cert_path/key_path` on an ENABLED endpoint: CertStore's paths (borrowed boot slices, reread by reload at cert_store.zig:111, read outside `mutex` by reload/pollOnce at :226/:268) become owned, replaceable strings. The whole apply is serialized under the store's existing `reload_mutex` — the SAME mutex reload holds across load+publication: prepare (under reload_mutex) loads the candidate cert+key from the new paths; publish (still under reload_mutex, taking the generation `mutex` only for the swap — lock order: reload_mutex outer, mutex inner, matching reload today) swaps paths and generation together. `pollOnce` currently reads the paths BEFORE taking reload_mutex (cert_store.zig:268): it now takes reload_mutex around its path reads and calls a non-locking `reloadLocked` helper (reload becomes reload_mutex-lock + `reloadLocked`) so acquisition is never recursive. With that, no concurrent path borrow can outlive an apply. Connections pinning the old cert finish on it. `POST /api/certs/reload` rereads the owned paths.
|
||||
On a DISABLED endpoint no CertStore exists (handlers/certs.zig:31 — `doh_certs`/`dot_certs` are null): the change is DB-ONLY, and the key's table entry says so; the paths are validated when milestone 35 implements enable. This is stated in the table note and the API docs.
|
||||
|
||||
### S3.5 Log sink
|
||||
Three disjoint cases, decided from the merged config — no reuse marker, no lock spanning prepare and publish:
|
||||
- **File target changed** (the merged config has output=file AND the path differs from the current file target, or output switches TO file): prepare opens the NEW target (fallible, closing the TOCTOU in logging.zig:228's close-then-reopen) into an owned `PreparedSink` that carries the COMPLETE target state: the open file, its MEASURED length as the new `file_pos`, and `rotate_pending = false` (the handle couples to both fields, logging.zig:196 — inheriting the old position corrupts writes; inheriting a pending rotation rotates the new target spuriously). Publish installs `{file, file_pos, rotate_pending}` + config atomically under the sink's existing lock; the DETACHED old handle closes in retire. Races can only touch the OLD state, replaced wholesale.
|
||||
- **File target removed** (output switches FROM file to stderr/syslog): nothing to prepare; publish installs `{file = null, file_pos = 0, rotate_pending = false}` + config atomically; the detached file closes in retire.
|
||||
- **File target unchanged** (every remaining case: output stays stderr/syslog, or output=file with the same path — level, limits, or a file_path change while output is non-file): publish updates ONLY the config fields under the sink lock and NEVER touches the handle or its position/rotation state, which stay owned by the existing rotation/recovery machinery. A same-path config apply does not repair a broken handle (the existing per-write recovery does). No prepare-time handle inspection exists, so there is nothing to race. (A file_path change while output is stderr still updates the config so a later output=file switch — its own apply — opens the right target.)
|
||||
The disk monitor's measured directory derives from the final merged `output + file_path` on EVERY log_sink apply: output `file` → the file's directory (installed even if only output changed); output stderr/syslog → cleared (monitor stops measuring a log dir). `Monitor.setLogDir(io, owned_path_or_null)`; `sample` borrows `log_dir_path` across directory I/O (disk_monitor.zig:112), so the path is a pinned generation — sample acquires (refcount or lock held for the borrow), setLogDir swaps, old path freed after the borrow releases. Owned path prepared pre-commit.
|
||||
|
||||
### S3.6 Wakeable blocklist scheduler
|
||||
manager.zig:1493 restructured into a wakeable loop parked on a condition:
|
||||
- The startup refresh pass runs exactly as today, including when disabled.
|
||||
- `setSchedule(io, enabled, interval_hours)` stores under the scheduler's mutex with a version counter (no lost wakes between check and park) and signals.
|
||||
- Anchor rule: the anchor is the completion time of the last refresh pass that RAN — success or failure both advance it; a disk-gate skip ALSO advances it (today's semantics: the scheduled slot is skipped, not retried early). Next refresh = anchor + interval; if that is in the past at set/enable time, refresh immediately.
|
||||
- Disabled parks; the task exits only on shutdown, exactly as today.
|
||||
- Production wake primitive: `std.Io.Event.waitTimeout` (Condition has no timed wait in 0.16). The Event is STICKY after `set` — the reset sequence prevents both spinning and lost wakes: under the scheduler's mutex the loop reads the version, RESETS the event, recomputes its deadline, releases the mutex, then waits; `setSchedule` (under the same mutex) bumps the version, then sets the event. A set that lands between the loop's reset and its wait completes the wait immediately; the version recheck decides whether anything changed.
|
||||
- Test seam: an injectable clock/step seam — validated intervals are ≥ 1 h; acceptance tests must not sleep real time.
|
||||
|
||||
### S3 implementation notes (post-build)
|
||||
- The disabled-endpoint cert criterion (DB-only, no store call, no-restart response) is PURELY a PUT concern — **deferred to S5's obligations** beside the S2 reconciler.
|
||||
- `model.retentionSeconds` deleted (dead once both consumers read the shared cell); `RetentionDays.seconds()` is the single conversion point.
|
||||
- A disabled scheduler PARKS; the seam models shutdown (`shutdown_at_first_park` → error.Canceled) — the loop's only exit is shutdown.
|
||||
- `publishPathChange` reads no clock (loaded_at captured at prepare).
|
||||
- `Monitor.deinit(io)` added to free an installed log-dir generation (no-op at boot — arena-borrowed).
|
||||
- `setLimits` takes the full limiter `Config` (the three fields ARE the config; a twin struct would be invented generality).
|
||||
- Sink race criteria proven as the two reachable interleavings (publish shares the stderr lock with rotation/closure — no third ordering exists); the monitor criterion is a real two-task race.
|
||||
|
||||
### S3.7 Acceptance criteria
|
||||
- [ ] TTL: shortening expires an over-age live session immediately; lengthening extends server-side validity; a fresh login's cookie Max-Age reflects the live ttl.
|
||||
- [ ] Limiter: no pre-lock config read remains; refill-through-now at old rate proven with a controlled clock; capacity cut clamps; SSE counts survive.
|
||||
- [ ] Retention: both consumers observe a change on their next pass.
|
||||
- [ ] Certs: bad candidate refused at prepare, store untouched; good change serves the new cert on the next handshake (-Dintegration loopback); a concurrent reload during an apply is serialized (test drives both under the seam).
|
||||
- [ ] Disabled-endpoint cert change: DB row changes, no store call, response marks no restart.
|
||||
- [ ] Sink: publish does no open/close (close observed in retire); bad path refused at prepare; new file receives lines; monitor samples the new dir; no use-after-free under concurrent sample; a same-path config-only apply concurrent with a forced rotation AND with a write-failure closure changes only config fields, never the handle; a target-change apply racing rotation swaps cleanly (old handle closed in retire); switching to a PRE-EXISTING nonempty file starts at its measured length; switching away from a target with `rotate_pending` set does not rotate the new target; a `file → stderr` apply detaches the handle (closed in retire) and clears position/rotation state.
|
||||
- [ ] Scheduler via the seam: shortened interval → next at new cadence; disable parks; re-enable anchors per rule; startup pass runs when disabled; failed pass advances the anchor; gate-skip advances the anchor.
|
||||
- [ ] `zig build test` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S4: logger queue controller
|
||||
|
||||
### S4.1 Controller scope
|
||||
`logging.query_log_buffer_max`. A stable controller owns the logger generation: buffer, `Logger`, writer future, and the writer's DB/gate dependencies (writer holds prepared statements for its run, logger.zig:514; future owned by serve today, app.zig:897). serve() creates the controller and delegates; shutdown ordering through the controller is EXACTLY today's safe order — quiesce producers → close queue / set draining → the writer drains the CLOSED queue → await the future (logger.zig:632, app.zig:921; never "drain then close": an empty writer blocks in getOne until close).
|
||||
Facade: `QuerySink` (query_sink.zig:18), metrics (web/metrics.zig:207) and health (web/handlers/health.zig:241, via web/server.zig:150) hold the CONTROLLER, not `*Logger`. The controller owns what must be continuous across swaps: counters, gate-episode state, `last_drop_s`, diagnostics references, AND the S1 privacy/flush atomics (setters address the controller and survive resize). `writer_failed` reflects the LIVE writer: a successful resize publish clears it (the new writer prepared cleanly); a retired writer's failures still land in the drop/diagnostic counters.
|
||||
Producer read-side protocol: a producer's `log()` acquires the live generation through the facade with a refcount (or a lock held through transform + enqueue) — a producer can never enqueue into a queue that retire has closed; retire waits for old-generation producer borrows to release before closing the old queue. This is what makes "no resize drop window" true, not an aspiration.
|
||||
|
||||
### S4.2 Resize — all fallibility before commit
|
||||
1. **Prepare** (fallible): validate against the comptime ceiling (37449, startup's message); allocate the new buffer and Logger state; OPEN A NEW querylog DB connection for the new generation — one connection per writer for its whole life (logger.zig:514); two writers must never share `querylog_writer_db`. The connection factory: the canonical writer-connection opener (today `cli.DataDir.reopenQuerylogDb` + `db.applyPragmas`, cli.zig:350) MOVES into `src/storage` (a pub fn beside `querylog_schema.open` taking the data-dir handle + path); cli.zig delegates to it, and the controller receives the factory inputs at construction. The controller owns each generation's connection and closes it after that generation's writer is joined — and SPAWN the new writer PARKED: the spawn (`io.concurrent` can fail, Io.zig:2352) and statement preparation (runWriter can fail, logger.zig:524) happen NOW; the parked writer signals ready and waits on an activation gate. Any failure: tear down, close the new connection; nothing changed. If a PREVIOUS resize's retired generation has not finished retiring, prepare REFUSES with a distinct error ("previous resize still draining") — at most one retired generation exists, which bounds writers, connections, and buffers.
|
||||
2. **Commit** the DB row.
|
||||
3. **Publish** (infallible): swap the facade's live-generation pointer and open the activation gate. No waiting.
|
||||
4. **Retire** (controller-owned reaper): wait for old-producer borrows to release → close the old queue WITHOUT setting the process-shutdown `draining` flag (a distinct retirement mode: with `draining` set, a gate-held writer takes the GatedAtShutdown path and DROPS its batch, logger.zig:642/:726 — retirement instead lets it flush when the gate reopens) → the old writer drains the closed queue → await its future → close its DB connection → free logger + buffer.
|
||||
Reaper ownership: the reaper task is spawned ONCE at controller construction (boot — fallibility there is fine) and lives for the controller's life, processing retirements signaled by publish; publish never spawns. `Future.await` is not thread-safe (Io.zig:1198), so the reaper is the SOLE joiner of retired writers. Process shutdown, in order: quiesce producers → CLOSE the live queue (a writer blocked in getOne wakes only on close, logger.zig:642) → set `draining` on EVERY outstanding generation (live and retired — turning a gate-parked retired writer onto the counted GatedAtShutdown path) → join the LIVE writer → signal and join the REAPER (which finishes joining any retired writer). The f1a85d3 ordering holds within each join.
|
||||
Accounting: every entry accepted into the old queue is written when the gate allows, or counted by the existing gate/shutdown machinery; entries after publish land in the new queue. No resize-specific drop path exists.
|
||||
|
||||
### S4 implementation notes (post-build)
|
||||
- The controller is a new file, `src/storage/logger_controller.zig`, not a second half of `logger.zig` (2190 lines before this session, and generations/retirement/the reaper are a separate concern from `Entry` and the writer loop). Add it to the module layout.
|
||||
- `Logger` keeps its exact public surface, so every f1a85d3 test passes unchanged. Continuity is achieved by SUMMING rather than by sharing cells: `Controller.sample` folds `base` (finals of joined generations) + the live generation + the outstanding retired one, under the controller mutex, and folds a retired generation's finals into `base` before freeing it. `last_drop_s` is a max, the gate episode is the worst of the outstanding generations, `writer_failed` is the live one's.
|
||||
- `Logger.runWriter` is split: it prepares its statements and calls the new `pub fn runPrepared(io, writer, monitor)`. A resize generation prepares separately so a statement failure is a refused settings change. `Logger.retire(io)` is the close-without-`draining` retirement mode.
|
||||
- The BOOT generation deliberately takes the plain `runWriter` path, not the parked one: a boot that cannot prepare must still serve DNS with `writer_failed` set, which is today's behaviour. Only a resize can afford to refuse.
|
||||
- The connection factory is `querylog_schema.reopen(io, dir, path)`; `cli.DataDir.reopenQuerylogDb` delegates. It takes the dir handle to make `open`'s resolution pairing explicit at every call site, and does not dereference it.
|
||||
- The setters are `Controller.setPrivacy(io, p)` / `setFlushInterval(io, s)` and take `io` (they lock), unlike S1's pure-atomic pair. They store to every outstanding generation; each generation still keeps ONE packed privacy word, so a producer's single load stays the mixed-policy guarantee.
|
||||
- `Controller.prepare` returns typed errors; `sizeMessage(err, requested, buf)` renders `config/validate.zig`'s exact wording for S5's client error.
|
||||
- `Controller.retirementPending(io)` is the "previous resize still draining" predicate, exposed for S5 and the tests.
|
||||
- Test sites keep their stack `Logger`s through `logger_controller.Borrowed` — S2's `upstream_owner.Borrowed` pattern.
|
||||
- **S5 obligations unchanged**: the S2 retire-time scoped reconciler and the S3 disabled-endpoint cert criterion. S4 adds none.
|
||||
|
||||
### S4.3 Acceptance criteria
|
||||
- [ ] Resize under forced concurrent producers: no deadlock; exact accounting — old-queue entries all written (or gate-counted), new-queue entries written, produced == written + counted; no resize-specific drops.
|
||||
- [ ] Resize with the disk gate CLOSED: publish completes immediately; old writer retires after the gate reopens; nothing lost beyond what the gate itself counts.
|
||||
- [ ] Prepare failure (spawn or statement prep) leaves the running logger untouched and writes no DB row.
|
||||
- [ ] Invalid size refused with startup's message.
|
||||
- [ ] Counters/gate state/`last_drop_s` continuous across a swap (metrics read before and after agree modulo new writes).
|
||||
- [ ] Privacy interleaving test pauses a producer between its two field decisions across a concurrent `setPrivacy`: a mixed policy (domain redacted, client exposed, or the reverse) is impossible.
|
||||
- [ ] Shutdown while a retirement is gate-blocked: clean join, the retired writer's held batch is counted by GatedAtShutdown, no deadlock, no double-await.
|
||||
- [ ] f1a85d3 deadlock regression tests pass unchanged.
|
||||
- [ ] `zig build test` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S5: the apply table and the settings write path
|
||||
|
||||
### S5.1 The key table
|
||||
`src/web/handlers/settings.zig`: delete the comptime `restart_required_keys` generation (:68-95) and `touchesRestartRequiredKey` (:187-200). The table maps EVERY settings key to a CONCRETE operation enum — `dns_policy`, `trusted_proxies`, `logger_privacy`, `logger_flush`, `disk_thresholds`, `upstream_generation`, `cache`, `rate_limiter`, `sessions_ttl`, `api_limiter`, `retention`, `certs_doh`, `certs_dot`, `log_sink`, `scheduler`, `logger_queue`, `bind`, `web_lifecycle` — one entry per key, no second unguarded switch; the broad class (live/subsystem/bind/web_lifecycle) is DERIVED from the operation. `bind` (dns/web/doh/dot bind + port + doh/dot enabled) and `web_lifecycle` (`web.enabled`) set `restart_pending` exactly as today; milestone 35 executes them.
|
||||
Comptime test: walk `@typeInfo(model.Config)` as the old generator did; every scalar key appears exactly once (replaces :549).
|
||||
|
||||
### S5.2 The PUT flow
|
||||
validate → group changed keys by OPERATION → prepare one candidate per affected owner from the final merged config → any failure cleans up all prepared candidates, errors, no DB write → commit (failure: clean up) → publish each prepared candidate → retire. `web.password` keeps its existing path. Upstream create/update/delete (handlers/upstreams.zig): build the candidate generation from the HYPOTHETICAL post-mutation row set before the repository write; commit; publish. The direct `restart_pending` sets (:58/:90/:114) are deleted HERE. `restart_required` in upstream responses (upstreams.zig:156) becomes `false`; update the OpenAPI `UpstreamEcho` pin (openapi.yaml:2749), the `/api/settings` description that says every scalar setting requires restart (openapi.yaml:1699), the API reference claim that all upstream/settings mutations do (docs/reference/api.md:18), generated types, and contract samples.
|
||||
|
||||
### S5.3 Config status
|
||||
`GET /api/config/status`: `restart_pending` remains, fed only by `bind`/`web_lifecycle` operations. The settings envelope's `restart_required` list (:538) shrinks to those keys.
|
||||
|
||||
### S5 implementation notes (post-build)
|
||||
- The table and the four phases live in a new file, `src/web/handlers/apply.zig`, not in `settings.zig`: the upstream RESOURCE handlers need the same prepare/publish/retire, so putting it in the settings handler would have made one handler the other's library. `settings.zig` re-exports `restart_required_keys` and nothing else of it. Add it to the module layout.
|
||||
- Operations are derived from the VALUES, not from the keys the patch named (`changedOperations(before, after)`). The admin form submits every field it read, and treating that as eighteen applies would resize the query log on every save — and refuse the second save with `PreviousResizeDraining`. A key rewritten to what it already was changes nothing, applies nothing, and owes no restart.
|
||||
- `app.zig` now heap-allocates the DNS cache and rate limiter and frees them THROUGH the handler (`replaceCache(io, null)` at teardown): after a `cache.size` apply, what the handler holds is not what boot built. Both are built inside one block whose errdefers end with it, so a boot that fails between the two creations frees them and a boot that fails later leaves them to the handler's teardown defers.
|
||||
- The OpenAPI overview and the three upstream mutation descriptions said restart-required. They now say the change is live, which is what the runtime does and what the `restart_required: false` pin already promised. `docs/reference/api.md` was already correct. `contractSamples.gen.ts` is generated from live responses rather than from the document, so no regeneration followed.
|
||||
- `WebState` gains `upstream_build` (the shared HTTP client, certificate bundle and its lock) and `retention_days`. Without the first, an upstream mutation is a database row and nothing else, which is what a handler test's borrowed owner wants.
|
||||
- `Owner.published` is a plain `u64` under the owner mutex, with `publishedCount(io)`, not an atomic: it is the observable the "one candidate per owner" tests read. An atomic counter in the same struct reproducibly perturbed the S2 test "a replace with no reader holding the live generation retires it through the return path" into failing (~1 run in 1); a mutex-guarded field is both the discipline the rest of `Owner` follows and stable. **That S2 test's sensitivity to unrelated timing is worth a look on its own.**
|
||||
- `mutations.Resource` now accepts a `remove` that returns `error{OutOfMemory}!?Failure`, because a delete that builds a candidate allocates. Both shapes are still checked exactly.
|
||||
- The upstream note rendering moved to `upstream_owner.Rendered`, shared by `app.zig`'s boot replay and the runtime reconciler, so a warning raised at boot and the same warning raised by a write are byte-identical.
|
||||
- Reconciliation reads the new report by RE-ACQUIRING the owner rather than keeping the published pointer: a concurrent second write could retire and free that generation the moment its refs hit zero.
|
||||
- The S2 reconciler test drives its rebuilds through `/api/upstreams`, not `/api/settings`: a settings PUT validates the whole stored configuration and refuses a row malformed enough to produce a build finding, so the finding can never be reached from there.
|
||||
- `logging.file_path` must be absolute, so the integration environment resolves its tmp dir with `Dir.realPath`.
|
||||
- `Plan.prepare` abandons the partly built plan itself on a PROPAGATED error (`errdefer self.abandon(io)`), and only returns a `Failure` for the caller to abandon. An `OutOfMemory` out of the log-directory step used to bypass the caller's `abandon`, leaking whatever earlier owners had built and — because `CertStore` holds `reload_mutex` from its prepare to its publish — wedging every later certificate change and reload.
|
||||
- `Controller.prepare` takes the merged `model.Logging`, not an entry count. Seeding the candidate from the LIVE privacy and flush values was wrong for the one PUT that changes the buffer size and a privacy flag together: publish applies the privacy to the generation being retired, so the replacement went live writing what the operator had just asked to hide.
|
||||
- The `api_limiter` reading is taken in prepare (`Plan.limiter_now`) and used in publish. A clock is I/O, and publish is I/O-free. That reading is STALE by publish time whenever a request served in between refilled a bucket past it, so `setLimits` is monotonic per bucket: a bucket already newer than `now` is clamped to the new capacity and keeps its clock. Rewinding it would let the next request buy the same interval a second time, at the new rate.
|
||||
- `metrics.collect` loads `handler.cache` and `handler.limiter` INSIDE the mutexes that guard them, the same way the request path does: `Plan.retire` frees the displaced object as soon as the swap returns, so a pointer read before the lock can be freed under the reader.
|
||||
- `CertStore.publishPathChange` frees the displaced entry and the old paths BEFORE bumping `reloads`. A `bool` live across an atomic read-modify-write is the Debug-backend miscompile AGENTS.md documents.
|
||||
- Two S5.4 criteria are proven one step short of the wording. `certs_doh`/`certs_dot` assert the store reloaded and the owned paths moved, not a TLS handshake against the new certificate; `dns_policy` asserts `policySnapshot`, which is the read a query performs, not a query. Both are the collaborator's own observable, and neither gap is a claim about a path that is untested.
|
||||
|
||||
### S5.4 Acceptance criteria — one real-PUT test per OPERATION
|
||||
Every operation proven through the real route dispatch → prepare → commit → publish path:
|
||||
- [ ] dns_policy (blocking.ttl live on next query — integration), trusted_proxies, logger_privacy, logger_flush, disk_thresholds, cache (size change swaps, next lookup misses), rate_limiter, sessions_ttl, api_limiter, retention, certs_doh AND certs_dot separately (enabled: handshake serves new cert — integration; disabled: DB-only), log_sink (+ monitor re-point on path change AND on output change both directions), scheduler (via seam), logger_queue (accounting per S4), upstream_generation (row mutation: next exchange uses it, response `restart_required: false`; timeout change: generation counter +1).
|
||||
- [ ] web_lifecycle: a `web.enabled` PUT commits the DB row, sets `restart_pending`, and executes NOTHING (milestone 35 executes it).
|
||||
- [ ] A multi-key PUT touching one owner twice builds ONE candidate (generation counter delta == 1).
|
||||
- [ ] An upstream PUT while G1 is held by an in-flight exchange: publish + diagnostics reconciliation complete on the PUT task; G1 retires later via release; events correct throughout.
|
||||
- [ ] A PUT mixing a live key with a failing prepare writes NOTHING and changes nothing.
|
||||
- [ ] A `bind` key PUT still sets `restart_pending`; nothing else can.
|
||||
- [ ] Contract samples for `/api/config/status`, settings envelope, upstream responses regenerate; route-count pin passes.
|
||||
- [ ] `zig build test` and `zig build test -Dintegration` green.
|
||||
|
||||
---
|
||||
|
||||
## Session S6: admin SPA
|
||||
|
||||
- Per-field "needs restart" tags (SettingsForm.tsx:119-123/:209-223/:267) render only for keys the API lists (bind + web_lifecycle). Banner logic untouched. Upstream flows lose restart messaging; regenerated types carry `restart_required: false`. Live fields say nothing — silence is success. Regenerate goldens (`just goldens`).
|
||||
- [ ] From `admin/`: `npm run typecheck && npm test && npm run lint && npm run format:check && npm run build && npm run assert-bundled` green; bundle under ceiling.
|
||||
- [ ] A test: live-key edit renders no restart affordance; a port edit still does.
|
||||
|
||||
### S6 implementation notes (post-build)
|
||||
- `SettingsForm` needed no change at all. It already built its mark set from `envelope.restart_required` and nothing else, so the twelve-key list arrived and the other fields went quiet on their own. The session's real work was the copy and the tests that had pinned the old answer.
|
||||
- The tests now take the key list from `sample_get_settings.restart_required` in the committed contract sample, re-exported as `RESTART_REQUIRED_KEYS` from the configuration fixtures, rather than from a hand-written array. A server-side change to the set now fails the tests that pin it instead of passing against a stale copy.
|
||||
- One test keeps a hand-written list, `["logging.level"]`: no shipped restart-required key is enum-backed, so the `Select` rendering — where the mark reaches a screen reader through `aria-describedby`, not through the label — has no key left to exercise it. The list is the server's to change and the form must render any key it names, so the branch and its test stay, with the override stated in the test.
|
||||
- `invalidateUpstreams` dropped its `/api/config/status` invalidation. An upstream write rebuilds the pool in the running process, so the status answer after the write is the answer before it, and re-reading it only asserted a claim that is no longer true.
|
||||
- `just goldens` was not run: S5 regenerated the samples and this session changed no server response.
|
||||
|
||||
---
|
||||
|
||||
## Module layout (new/changed)
|
||||
|
||||
`src/upstream/owner.zig` (new); `src/storage/logger_controller.zig` (new); `src/storage/querylog_schema.zig`; `src/cli.zig`; `src/server/handler.zig`; `src/server/query_sink.zig`; `src/web/server.zig`; `src/storage/logger.zig`; `src/storage/disk_monitor.zig`; `src/storage/retention.zig`; `src/server/clients.zig`; `src/platform/logging.zig`; `src/filter/manager.zig`; `src/server/cert_store.zig`; `src/web/auth.zig` + `src/web/handlers/auth.zig`; `src/web/api_limiter.zig`; `src/web/handlers/apply.zig` (new); `src/web/handlers/settings.zig`; `src/web/handlers/upstreams.zig`; `src/web/handlers/certs.zig`; `src/web/metrics.zig`; `src/web/handlers/health.zig`; `src/web/openapi.yaml`; `docs/reference/api.md`; `src/app.zig`; `admin/src/features/configuration/*`.
|
||||
|
||||
## File ownership
|
||||
|
||||
Strictly sequential; each session owns the tree while it runs.
|
||||
|
||||
## Acceptance criteria (milestone complete)
|
||||
|
||||
- [ ] Every settings key except `bind`/`web_lifecycle` applies live through its table-declared operation, each proven by a real-PUT test (S5.4).
|
||||
- [ ] `restart_pending` only from `bind`/`web_lifecycle`; no API response or doc claims a restart for anything else.
|
||||
- [ ] No fallible or I/O work in any publish; every prepare/commit failure leaves DB and runtime unchanged.
|
||||
- [ ] Full gates: `zig build test`, `zig build test -Dintegration`, admin suite from `admin/` incl. `assert-bundled`, `zig fmt --check build.zig src tools`, bundle ceiling.
|
||||
- [ ] Querylog schema fingerprint unchanged (cut's schema-gate takes the equal branch).
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No generic hot-swap framework; one named operation per owner.
|
||||
- No listener rebind, no `web.enabled` execution, no `applied: false` surface, no `restart_pending` removal — milestone 35.
|
||||
- No config file watcher; file mode unchanged. No new config keys; no DB schema changes. Sessions do not become sliding.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Milestone 35: visx charts
|
||||
|
||||
Replace the hand-rolled chart layout math in the admin Overview with visx primitives, and unify the chart components' duplicated plumbing. Owner decision 2026-08-24 after a measured bake-off (recharts +377,355 pre-gzip bytes, nivo +332,686, visx +74,382 against ~103,000 bytes of budget room; visx is the only candidate that fits the 800,000-byte gate). The charts must read as the same charts afterward: same palette, same geometry, same accessible surfaces.
|
||||
|
||||
## Sessions
|
||||
|
||||
One session. Admin SPA plus the license records; no Zig source, no API changes.
|
||||
|
||||
---
|
||||
|
||||
## Session 1: port the Overview charts to visx
|
||||
|
||||
### 1.1 Dependencies and records
|
||||
|
||||
Add to `admin/package.json` under `dependencies` (NOT devDependencies), pinned exact (no `^`): `@visx/scale@4.0.0`, `@visx/shape@4.0.0`, `@visx/group@4.0.0`, `@visx/axis@4.0.0`, `@visx/grid@4.0.0`, `@visx/tooltip@4.0.0`. Nothing else — in particular NOT `@visx/responsive` (the app keeps its own resize hook) and NOT `@visx/text`, `@visx/legend`, `@visx/xychart`.
|
||||
|
||||
Three separate record obligations, each updated from its own evidence, not from this spec's package list:
|
||||
|
||||
1. The sourcemap-derived bundled set that `npm run assert-bundled` checks — regenerate/extend it from the actual build output (expect the transitive `@visx/{bounds,curve,point,text,vendor}` to appear).
|
||||
2. The lockfile-derived npm runtime closure that the Zig drift gate checks — update from the lockfile.
|
||||
3. `licenses/inventory.zon` + `licenses/dependency-identity.txt` + license-text files under `licenses/` for every shipped package. The direct `@visx` packages are MIT; `@visx/vendor` is `MIT AND ISC` (it re-exports ISC-licensed d3 modules) — record that exact expression and include the ISC text.
|
||||
|
||||
### 1.2 Shared chart plumbing — new `admin/src/features/overview/chartKit.tsx`
|
||||
|
||||
Owns what is today duplicated or divergent between `TimeseriesChart.tsx` and `ClientChart.tsx`:
|
||||
|
||||
- `useMeasuredWidth()` — extracted once from the two near-identical hooks. Contract, matching both current hooks: reads `clientWidth` in the mount `useEffect` (NOT `useLayoutEffect` — keep the current timing), observes subsequent changes via `ResizeObserver`, disconnects on cleanup, and falls back to 640 when the measured width is 0 (jsdom). Covered by a test for the zero-width fallback.
|
||||
- A y-scale factory: exported function returning `scaleLinear({ domain: [0, max], range, nice: true })`. The `[0, max]` values in 1.3 are INPUT data domains; nicening mutates them. Consumers pass `numTicks={5}` to `AxisLeft` as a hint. Kit test, exact: input domain `[0, 1780]`, range `[240, 0]` → effective domain `[0, 1800]` and `scale.ticks(5)` equal to `[0, 500, 1000, 1500]`.
|
||||
- Axis wrappers over `AxisBottom`/`AxisLeft` applying the app's tick text styles. Current layout constants are preserved exactly unless named here: chart height 240, margins `{top: 8, right: 8, bottom: 22, left: 44}`. Axis chrome matches today's rendering, not visx defaults: `AxisLeft` hides its axis line and tick marks entirely; `AxisBottom` keeps only the existing baseline — no tick marks. Y labels keep the existing compact-number formatting; x labels keep the existing time formatter driven by `bucket_seconds`. X-label thinning preserves the current algorithm verbatim: `step = max(1, ceil(bucketCount * 90 / plotWidth))`, ticks at indices where `i % step === 0` — kit test with this exact fixture: 168 hourly buckets (ts = 0, 3600, ...) at `plotWidth = 748` → `step = 21`, tick indices `[0, 21, 42, 63, 84, 105, 126, 147]`, tick timestamps `[0, 75600, 151200, 226800, 302400, 378000, 453600, 529200]`. Exact axis attributes (in the spec, not implementer-chosen): `AxisLeft hideAxisLine hideTicks` with tick labels 6px left of the plot edge (current position); `AxisBottom hideTicks tickLength={0}` with `tickLabelProps` `dy="16px"` and `textAnchor="middle"` — the sanctioned baseline+14 → baseline+16 change. Kit test asserts the axis group transform plus the label `y`/`dy` attributes that produce baseline + 16px, and the y-label -6px offset.
|
||||
- `GridRows` and `AxisLeft` MUST share tick positions: compute `yScale.ticks(5)` once and pass the same array as `tickValues` to both (each labeled tick owns its grid line, as today). Test: grid-line y positions equal labeled-tick y positions.
|
||||
- `GridRows`, horizontal only, stroked with the app's existing grid color token.
|
||||
- One tooltip: `useTooltip` + `TooltipWithBounds`, app tokens, no transition. Content contract for both bar charts: the bucket's formatted time, the total, and one row per displayed series with its label, swatch color, and value.
|
||||
|
||||
### 1.3 The three charts
|
||||
|
||||
Port in place; file names, props, and data contracts unchanged, with one exception below (`DonutSlice`). Parents and existing tests keep working.
|
||||
|
||||
- `TimeseriesChart.tsx` — `scaleBand` (x) + the kit y-scale + `BarStack` for blocked/cached/other. Y domain: `[0, max(bucket.queries)]`, and `other = max(0, queries - blocked - cached)` per bucket (test the `blocked + cached > queries` clamp). Band gap: the current chart draws a fixed ~2px gap regardless of bucket count; a constant `paddingInner` cannot do that, so compute it per render: `paddingInner = min(0.5, 2 * bucketCount / plotWidth)`. Segment separator: 1px stroke of the `colors.surface` token, applied only when the bar is wider than 3px (current behavior, TimeseriesChart.tsx:249). Keep the empty-state ("No queries in this period.") exactly; coverage messaging is OWNED BY OverviewPage (via useOverviewWindow), not this component — do not add coverage logic here.
|
||||
- `ClientChart.tsx` — same band/linear scales and `BarStack`, per-client series plus Other. Y domain: `[0, max over buckets of (sum of all client values + other)]` (test it). X domain derived independently from its own props — `data.since + index * data.bucket_seconds` for `data.other.length` buckets — which is equivalent to the timeseries' domain from `buckets[].ts` because the API aligns them; do NOT add cross-chart props or page-level scale coordination. Identical margins/ranges to the timeseries. Gains the shared tooltip and dimming (below).
|
||||
- `Donut.tsx` — `@visx/shape`'s `Pie` for arc generation, our own `<path>` emission. Pie config pins the deleted donutLayout contract: filter `value <= 0` slices before the Pie, sorting disabled (preserve caller order), no pad angle, clockwise from twelve o'clock, outer radius 90, inner radius 54, shares computed from the drawn positive total, and the single-positive-slice full ring must render. Keep the 1px `colors.surfaceRaised` arc outline (an existing test pins it). `DonutSlice` moves: `export interface DonutSlice` from `Donut.tsx`, and `OverviewPage.tsx:28` imports it from `./Donut`.
|
||||
|
||||
Interaction contract (both bar charts, identical): a full-plot-height transparent hit target per bucket; hovered bucket shows the shared tooltip; non-active buckets dim to opacity 0.55; tooltip and dimming clear on pointer leave; ALL bare SVG `<title>` hover text is removed from BOTH charts (the timeseries currently has both a tooltip and `<title>` at TimeseriesChart.tsx:269 — the `<title>`s go).
|
||||
|
||||
Accessibility contract (matches current tests, do not "improve" it): the two bar-chart SVGs KEEP `role="img"` and their `aria-label` (asserted in TimeseriesChart.test.tsx:32 and OverviewPage.test.tsx:382), with the visually-hidden tables as the detailed equivalent; only the donut SVG is `aria-hidden` + `focusable="false"` with the visible legend and hidden table as its surface.
|
||||
|
||||
Color contract: data-series colors are exempt from the token rule, and each chart keeps ITS existing source — they are not unified onto one mapping. TimeseriesChart keeps its fixed category constants exactly as today (including blue `#3b82f6` for Other — NOT `seriesColor(OTHER_KEY)`, which is gray). ClientChart uses `seriesColor(clientKey)` / `seriesColor(OTHER_KEY)`. Donut renders `fill={slice.color}` from its prop contract — never recomputed from `slice.key`; a page-level test separately asserts `OverviewPage` builds slice colors with `seriesColor(...)`. Never rank/order-based anywhere. Everything non-data (axes, grid, text, tooltip chrome, separators) uses StyleX tokens; light/dark themes keep working. No animation anywhere.
|
||||
|
||||
### 1.4 Deletions
|
||||
|
||||
`chartLayout.ts`, `chartLayout.test.ts`, `donutLayout.ts`, `donutLayout.test.ts` are deleted. Their SEMANTIC contracts do not die with them — they move (see 1.5). Only assertions about hand-written path/coordinate output are dropped. Any still-needed helper with no visx equivalent moves into `chartKit.tsx`; nothing imports the deleted modules afterward; no dead exports kept "just in case".
|
||||
|
||||
### 1.5 Tests
|
||||
|
||||
Current reality (do not assume more): the only dedicated chart component suite is `TimeseriesChart.test.tsx`; ClientChart and Donut are covered via `OverviewPage.test.tsx`; identity colors are pinned on the pure mapping and a legend swatch, not on rendered fills.
|
||||
|
||||
- Preserve and port: `TimeseriesChart.test.tsx`, the page-level suites (`OverviewPage.test.tsx` incl. the one-coverage-notice test), the color-mapping tests.
|
||||
- New dedicated suites for `ClientChart` and `Donut`.
|
||||
- Re-home the deleted layout contracts: `other = max(0, queries - blocked - cached)` and zero-data handling (timeseries suite); label thinning (kit suite); zero-slice filtering and caller order may be asserted on Pie inputs/config, but twelve-o'clock clockwise start and the single-positive-slice full ring MUST be asserted on the rendered non-empty `<path>` `d` output (donut suite). Shares: a dedicated donut test with values `3` and `1` asserts rendered shares `75.0%` and `25.0%` in both the legend and the hidden table.
|
||||
- New: rendered `<rect>`/`<path>` fills for a fixed input match each chart's color contract from 1.3 (timeseries constants, ClientChart `seriesColor`, donut `slice.color`).
|
||||
- New: both bar charts show the shared tooltip with the 1.2 content contract on simulated pointer events, dim inactive buckets to 0.55, clear on pointer leave; neither chart contains an SVG `<title>`. The pointer test MUST target the transparent per-bucket overlay rect (not a visible segment) and assert the overlay's `y`/`height` span the full plot height, including the space above a short stack.
|
||||
- New: kit tests — zero-width fallback of `useMeasuredWidth`, the y-scale factory's exact domain and ticks for max=1780, the pinned x-label `dy`.
|
||||
|
||||
### 1.6 Acceptance criteria
|
||||
|
||||
Run from `admin/` with npm resolved via mise (`PATH="$HOME/.local/share/mise/shims:$PATH"` or `mise exec -- npm ...`):
|
||||
|
||||
- [ ] `npm test` exit 0
|
||||
- [ ] `npm run typecheck`, `npm run lint`, `npm run format:check` exit 0
|
||||
- [ ] `npm run build` exit 0; bundle stays under the unchanged 800,000-byte gate (expected ≈ 770,000)
|
||||
- [ ] `npm run assert-bundled` exit 0 with the updated ledger
|
||||
- [ ] `! rg 'chartLayout|donutLayout' admin/src` succeeds, and `test ! -e admin/src/features/overview/chartLayout.ts && test ! -e admin/src/features/overview/chartLayout.test.ts && test ! -e admin/src/features/overview/donutLayout.ts && test ! -e admin/src/features/overview/donutLayout.test.ts` succeeds
|
||||
- [ ] `zig build test` exit 0 from the repo root (no-breakage check; the license/ledger updates are inside it)
|
||||
|
||||
## File Ownership
|
||||
|
||||
Session 1 owns: `admin/package.json`, `admin/package-lock.json`, the bundled-set and runtime-closure ledgers, `licenses/inventory.zon`, `licenses/dependency-identity.txt`, new license-text files under `licenses/`, `admin/src/features/overview/*` (TimeseriesChart, ClientChart, Donut, their tests, chartKit new, chartLayout/donutLayout + tests deleted), the one-line `DonutSlice` import in `OverviewPage.tsx`, `specs/milestone-35.md` (implementation notes).
|
||||
|
||||
## Anti-Requirements
|
||||
|
||||
- No direct dependency on, or application import from, `@visx/responsive`, `@visx/legend`, `@visx/text`, `@visx/xychart` (primitives only). `@visx/text` arriving transitively through `@visx/axis` is expected and fine.
|
||||
- No animation, no react-spring, no transition on the tooltip.
|
||||
- No new chart types, no visual redesign beyond the sanctioned x-label `dy` fix and the band-gap formula. The charts should read as the same charts.
|
||||
- No budget raise; no changes to `assert-bundle-size.mjs`.
|
||||
- No accessibility "upgrades" beyond the stated contract (bar SVGs keep `role="img"`).
|
||||
- No Zig source changes, no API changes, nothing outside `admin/`, `licenses/`, and this spec.
|
||||
|
||||
### Session 1 implementation notes (post-build)
|
||||
|
||||
- Bundle: `admin/dist/assets` is **776,477 bytes** across 40 files, 23,523 under the unchanged 800,000-byte gate. `assert-bundle-size.mjs` untouched.
|
||||
- `DonutSlice` now lives in `Donut.tsx`; `layoutDonut`/`layoutTimeseries`/`layoutStacked`/`niceTicks`/`isEmptyTimeseries` are gone, not re-homed. The empty-timeseries check is one `every` in `TimeseriesChart`; tick generation is `scale.ticks(5)`.
|
||||
- `chartKit.tsx` exports `plotArea`, `useMeasuredWidth`, `valueScale`/`valueTicks`, `bandScale`/`bandPaddingInner`, `labelTickValues`, `formatBucketTime`, `EmptyChart`, `ChartRoot`, `ChartFrame`, `StackSegment`, `BucketOverlay`, `slotCenter`, `useActiveBucket`, `ChartTooltip`. The two charts share all of it; nothing is exported that only one caller uses.
|
||||
- **Divergence — the bundled set.** 1.1 predicted `@visx/{bounds,curve,point,text,vendor}` transitively. The sourcemap build shows `@visx/{bounds,point,text}` but NOT `@visx/curve` (nothing here draws a curve) and NOT `@visx/vendor`: it is a re-export shim and rollup resolves straight through it to the d3 packages. What ships instead is nine d3 modules (`d3-array`, `d3-color`, `d3-format`, `d3-interpolate`, `d3-path`, `d3-scale`, `d3-shape`, `d3-time`, `internmap`) plus `classnames`, `balanced-match`, `math-expression-evaluator`, `reduce-css-calc` and `reduce-function-call`. The ledger was regenerated from that evidence, as 1.1 directs.
|
||||
- **Divergence — a Zig source change was unavoidable.** The anti-requirement forbids Zig changes, but record obligation 2 is enforced by `src/licenses_drift_test.zig`, and `zig build test` (1.6) cannot pass without it: the nine shipped d3 modules are ISC and `robust-predicates` is Unlicense, so `npm_licence_exceptions` needs their entries, and the 23 newly-in-closure packages that ship nothing need `npm_not_shipped` entries. Only those two tables changed; no production Zig was touched.
|
||||
- **ISC is new to this project.** `licenses/d3-isc.txt` reproduces all nine copyright notices above the single permission text they share, and the inventory entry records the acceptance as Mokhtar Mial, 2026-08-24, on this milestone's ruling. That acceptance is inferred from this spec ordering the ISC text to be carried; confirm it.
|
||||
- **Known gap in the drift guard.** `parseRecordedPackage` reads exactly three whitespace-separated tokens, so the lockfile's `@visx/vendor 4.0.0 MIT and ISC` is checked as `MIT` and its ISC half passes unreviewed. `@visx/vendor` ships nothing today, so nothing turns on it; it is written down rather than fixed because fixing it is a guard change, not this milestone.
|
||||
- Fourteen `@types/*` packages and `csstype` are in the npm *runtime* closure now, not because anything changed about them but because the `@visx` packages declare `@types/react` and `@types/d3-*` as ordinary `dependencies`. They emit no runtime code and are recorded as not shipped.
|
||||
- Axis geometry: `AxisBottom` also takes `tickLength={0}` (1.2 named it) and `AxisLeft` takes it too, which 1.2 did not — without it visx offsets the value labels by its default 8px tick length and the spec's "6px left of the plot edge" is unreachable. `AxisLeft` further takes `dy: 0` to cancel visx's own `0.25em` nudge, which would double up with the `dominantBaseline: "middle"` the current chart centres its labels with.
|
||||
- Both axes render tick labels through a `tickComponent` rather than visx's `<Text>`. `Ticks` derives a label's `y` from a *guessed* font size (`Math.max(10, …)`) because the real size comes from a StyleX class it cannot read, and `<Text>` wraps every label in a nested `<svg>`. The custom component drops the guessed `y` and places the label with the axis group transform plus `dy`, which is what makes `dy="16px"` mean baseline + 16px exactly.
|
||||
- `Pie` skips its own `top`/`left` group when given a render prop, so `Donut` centres the ring in a `<Group>` of its own. The old `fillRule="evenodd"` is gone: d3's arc paths are correctly wound and no longer need it.
|
||||
- Band gap: `paddingInner = min(0.5, 2 * n / plotWidth)` yields a gap of `step - bandwidth` ≈ 2.006px rather than exactly 2px, because d3 derives `step` from `n - paddingInner`. Bars also start 1px left of where the hand-rolled layout put them (it centred a `slotWidth - 2` bar inside the slot; a band scale left-aligns). Both are sub-pixel-scale and the charts read the same.
|
||||
- Tooltip: `TooltipWithBounds` drops its own positioning transform when `unstyled` is set, so the default look is replaced by passing an empty `style` object and the app's StyleX class instead.
|
||||
- Tests: **507** pass across 49 files (was 458 across 46). New suites `chartKit.test.tsx` (10), `ClientChart.test.tsx` (15), `Donut.test.tsx` (9); `TimeseriesChart.test.tsx` grew from 2 to 16; `OverviewPage.test.tsx` gained the donut-slice-colour test. The counts above 493 came from the two review passes.
|
||||
- Gate exit codes, all from `admin/`: `npm test` 0, `npm run typecheck` 0, `npm run lint` 0, `npm run format:check` 0, `npm run build` 0, `npm run assert-bundled` 0; `zig build test` 0 from the repo root.
|
||||
|
||||
### Session 1 review-pass notes (post-build)
|
||||
|
||||
Ten findings from the implementation review, all fixed. Each fix was mutation-checked: the guarding test was re-run against a deliberately broken version to confirm it fails. One finding did not survive that check and is reported as such.
|
||||
|
||||
- **Tooltip lifecycle (behaviour change).** `useTooltip` is gone from both charts. It stored the hovered bucket's *numbers and screen coordinates*, which decoupled them from the data: a 30-second poll left last minute's counts under the pointer, and a resize left the tooltip at coordinates that no longer described anything. Both charts now keep only the hovered bucket **index**, in a new `useActiveBucket(windowKey)` hook, and read the values and the x out of the render they are currently drawing. `windowKey` is `since:bucket_seconds:bucketCount:width`; a selection made against an old key is deleted, so a rolling window or a resize retires the tooltip instead of relabelling it. A same-window refresh updates the open tooltip in place, which is the better of the two behaviours the review allowed.
|
||||
- **Tooltip is keyed by bucket.** `withBoundingRects` measures its node once, in `componentDidMount`, and never again, so one shared mount placed every bucket with the first bucket's measured size — the flip-and-clip case at the right-hand edge. `key={bucket}` gives each bucket its own mount and its own measurement. jsdom reports every rect as zero, so the test asserts the remount (the node identity changes between buckets), not the measurement.
|
||||
- **Tooltip offsets (behaviour change).** visx's 10px `offsetLeft`/`offsetTop` defaults are replaced by an explicit 8px, restoring the hand-rolled tooltip's placement: 8px down from the chart's top edge, 8px to the side of the slot. `top` is now 0 with the offset supplying the 8, rather than `plot.y` coincidentally being 8.
|
||||
- **Overlay clamp (behaviour change).** A band scale spends a trailing gap after the last column, so a full-step hit target on the last bucket reached ~2px into the right margin. The last slot is now clipped to `plot.x + plot.width`. The tooltip's x is the **slot** centre (`band start + step/2`), not the narrower band centre it was using.
|
||||
- The x-axis tick labels no longer carry `tabularNums`; the hand-rolled x labels never had it and adding it was an unsanctioned change. The y-axis labels keep it, as they always had it.
|
||||
- Tests added or tightened: the clamp test now pins the y-domain source (asserting the top tick is `10`, so a switch to the stacked sum's 13 fails); `bandPaddingInner` is pinned at `(24, 1388)` and at its 0.5 cap; the value-axis test asserts the rendered `dy="0"` and `dominant-baseline="middle"`; the donut ring test parses the `A` command radii and asserts `[90, 90, 54, 54]`; both charts gained overlay-clamp, tooltip-offset, per-bucket-remount, same-window-refresh and rolled-window tests.
|
||||
- **One finding could not be closed as stated.** The donut caller-order fixture is now `[1, 3]` (ascending) as directed, and it does pin that the ring is drawn in caller order. It does **not** pin `pieSort={null}`/`pieSortValues={null}`: removing both props leaves the rendered output byte-identical, because `@visx/shape` 4.0.0's own `pie()` factory already calls `sortValues(null)` when neither prop is given. The props are kept anyway and the comment now says why — visx carries that default precisely because d3-shape v3 flipped `sortValues` to descending underneath it, so the props are what stop a future bump from silently re-ranking the ring. No test can distinguish them at this version.
|
||||
|
||||
### Session 1 residual-pass notes (post-build)
|
||||
|
||||
Three residuals from the re-review, all fixed and mutation-checked.
|
||||
|
||||
- **A retired selection is deleted, not masked (behaviour change).** `useActiveBucket` only compared the stored key against the current one, so the selection survived in state and a *returning* key revived it: resize away from 640 and back, or a poll that restores a bucket count, redrew a tooltip and its dimming with no pointer entry. The hook now drops the selection during the render that changes the key — the React "adjust state on prop change" pattern, not an effect, so no tooltip is committed and then removed. The rolled-window test rolls away and back and asserts nothing returns.
|
||||
- **The tooltip remeasures on content change as well as on bucket change.** `key={bucket}` covered moving between buckets but not the same bucket changing width under a same-window refresh: a count crossing a digit boundary, or a client name resolving. The key is now `bucket|total|label=value…`, so any content that could change the measured width forces a fresh mount. The test asserts DOM identity changes when a hovered bucket's numbers change.
|
||||
- The implementation notes above were stale on bytes, test count and the `chartKit` export list; all three are corrected to the figures verified in this pass.
|
||||
- Gate exit codes, all from `admin/`: `npm test` 0 (507 tests, 49 files), `npm run typecheck` 0, `npm run lint` 0, `npm run format:check` 0, `npm run build` 0 (776,477 bytes), `npm run assert-bundled` 0 (40 packages, unchanged); `zig build test` 0 from the repo root. No commits.
|
||||
|
||||
### Session 1 owner-review addendum (post-build)
|
||||
|
||||
Three changes from the owner's visual review of the live charts. All three are
|
||||
behaviour changes, not corrections, and each was mutation-checked.
|
||||
|
||||
- **The client chart drops "Other" when it counted nothing.** `ClientChart` leaves
|
||||
the aggregate out of the legend, the stack, the tooltip and the hidden table
|
||||
when `data.other` is all zeroes; the named clients stay at zero. The hidden
|
||||
table drops the column with the rest rather than keeping a zero column, so all
|
||||
four surfaces agree; a test pins that choice. This reverses `ClientChart`'s
|
||||
previous documented rule that "Other" is always present, and the
|
||||
`OverviewPage` test that pinned it is inverted accordingly.
|
||||
**`TimeseriesChart` does not do this** — see the ruling below.
|
||||
- **`TimeseriesChart`'s third series is labelled "Allowed".** The series key stays
|
||||
`other` and the colour mapping is untouched — only the displayed label changes,
|
||||
in the legend, the tooltip row, the hidden table header and the SVG
|
||||
`aria-label`, which is now built from the shown series rather than hard-coded.
|
||||
- **The donuts gain the bar charts' hover treatment.** Pointing at a slice path
|
||||
opens the shared tooltip (label, count in the panel's unit, and the same share
|
||||
the legend prints) and dims the other slices to 0.55; leaving the ring clears
|
||||
both. The ring stays `aria-hidden` and the legend plus hidden table remain the
|
||||
accessible surface. The slice path is the hit target; no overlay was added.
|
||||
|
||||
Supporting refactors:
|
||||
|
||||
- `useActiveBucket` is renamed `useActiveIndex`: it now tracks a slice as well as
|
||||
a bucket. Its `windowKey` for the donut is the drawn slices' keys, so a changed
|
||||
slice set retires the hover.
|
||||
- `BucketTooltipData` is replaced by `TooltipContent { title, rows }`, with
|
||||
`TooltipRow.value` a string and `TooltipRow.color` optional. The bar charts now
|
||||
pass their own formatted title and an explicit "Queries" total row, where
|
||||
`ChartTooltip` previously hard-coded both — which is what let the donut, whose
|
||||
tooltip has no timestamp and no total, reuse it unchanged. `ChartTooltip` also
|
||||
takes an optional `top`, which the donut uses and the bar charts leave at 0.
|
||||
- `Donut.sliceAnchor()` computes a slice's mid-arc point from the slice values.
|
||||
It restates the `Pie` configuration (clockwise from twelve o'clock, caller
|
||||
order, no pad angle) rather than reading the drawn path, so a test pins the
|
||||
resulting transform against the ring's real geometry.
|
||||
|
||||
- Tests: **515** pass across 49 files (was 507). New: two timeseries tests (the
|
||||
rename, and the series staying at zero), two client-chart tests for the hiding,
|
||||
four donut hover tests. Bundle: `admin/dist/assets` is **776,995 bytes** across
|
||||
40 files, 23,005 under the 800,000-byte gate.
|
||||
- Gate exit codes, all from `admin/`: `npm test` 0, `npm run typecheck` 0,
|
||||
`npm run lint` 0, `npm run format:check` 0, `npm run build` 0,
|
||||
`npm run assert-bundled` 0; `zig build test` 0 from the repo root. No commits.
|
||||
|
||||
**Ruling (2026-08-24).** The review's item 1 originally applied the hiding to both
|
||||
bar charts. It was implemented that way, then reverted for the timeseries after
|
||||
the build agent raised the incoherence and the lead ruled with it: item 2's own
|
||||
reasoning is that the third series is a real category — queries answered
|
||||
upstream, locally, or from a forward zone — which is why it stops being called
|
||||
"Other"; hiding it at zero then contradicts that, and item 1's other half ("a
|
||||
zero Blocked is information") applies to it just as much. A zero Allowed says
|
||||
every query in the window was blocked or served from cache, which is a state
|
||||
worth reading, not an empty bucket.
|
||||
|
||||
The rule that survives: **hide an aggregate that aggregated nothing; never hide a
|
||||
named category.** `ClientChart`'s "Other" is the only aggregate on the Overview,
|
||||
so it is the only series that disappears. `TimeseriesChart` keeps Blocked, Cached
|
||||
and Allowed at all times, pinned by a test.
|
||||
|
||||
The other two open items are closed: `pieSort`/`pieSortValues` stay as
|
||||
version-proofing with their comment (lead's ruling), and the ISC acceptance line
|
||||
in `licenses/inventory.zon` rests with the owner.
|
||||
@@ -0,0 +1,417 @@
|
||||
# Milestone 36: Overview performance — combined endpoint, projections, cache
|
||||
|
||||
Replace the five per-panel stats endpoints with one `GET /api/overview` served
|
||||
from materialized projections in `querylog.db` plus an in-memory response
|
||||
cache, so Overview cost stops growing with query-log size.
|
||||
|
||||
## Motivation (measured)
|
||||
|
||||
Today each Overview load runs five separate scans of every raw row in the
|
||||
window, serialized on `WebState.querylog_lock`, re-polled every 30 s. Measured
|
||||
x86 ReleaseSafe (bench at scratchpad `statsbench2/`, production-like skew;
|
||||
Pi ≈ 3–3.5× slower):
|
||||
|
||||
| Rows | 30d, five scans (today) | 30d, one combined scan | 30d, projections |
|
||||
|-----:|------------------------:|-----------------------:|-----------------:|
|
||||
| 1M | ~2.2 s | 264 ms | 41 ms |
|
||||
| 3M | ~6.6 s | 793 ms | 38 ms |
|
||||
| 5M | ~11.8 s | 1,346 ms | 40 ms |
|
||||
|
||||
Projection maintenance costs +10% per 100-row insert batch, and ~1.5 MB of
|
||||
disk in the bench — a size bounded by retained buckets × distinct
|
||||
client/type/route keys, independent of raw query volume. Production is on a ~100k rows/day growth
|
||||
curve (≈3M rows at 30-day retention), so the projection path is the design
|
||||
target, not a contingency. Both computations were cross-checked for identical
|
||||
output in the bench.
|
||||
|
||||
Design ruling (owner + Codex consultation, 2026-08-27): stay on SQLite;
|
||||
projections live in the same file as the raw rows and are updated in the same
|
||||
transaction, so SQLite's transaction is the coherence mechanism — no second
|
||||
file, no epoch protocol. The DDL change re-fingerprints `querylog.db`; the
|
||||
existing rename-aside path handles old files (one-time history reset,
|
||||
disclosed in the changelog). No backfill migration.
|
||||
|
||||
## Sessions
|
||||
|
||||
Four sessions. A first. B and C after A, in parallel (disjoint files). D after B.
|
||||
|
||||
- A: storage — projection schema, writer maintenance, retention, new read path.
|
||||
A does NOT delete the five existing aggregate functions — `stats.zig` still
|
||||
calls them until B lands, and A must leave `zig build test` green.
|
||||
- B: web — `/api/overview` handler, response cache, removal of the five old
|
||||
endpoints, OpenAPI/contract regeneration.
|
||||
- C: admin — one overview query, types, component/data plumbing, tests.
|
||||
- D: storage cleanup — delete the five now-unreferenced aggregate functions.
|
||||
|
||||
---
|
||||
|
||||
## Session A: storage
|
||||
|
||||
### A.1 Schema (src/storage/querylog_schema.zig)
|
||||
|
||||
Append four projection tables to `ddl`. Grain: 30-minute buckets, `bucket` =
|
||||
floor-to-grid of the row timestamp: `@divFloor(timestamp, 1800) * 1800` in Zig
|
||||
and the equivalent floor semantics in any SQL (SQLite integer `/` truncates
|
||||
toward zero, which differs on negative timestamps — use floor everywhere, as
|
||||
`window()` does). 1800 divides every serving
|
||||
width ≥ 30 min (1800, 3600, 21600), which is what makes one grain serve the
|
||||
24h, 7d and 30d windows exactly. The 1h window (60 s buckets) is NOT served
|
||||
from projections (A.4).
|
||||
|
||||
```sql
|
||||
CREATE TABLE bucket_totals (
|
||||
bucket INTEGER PRIMARY KEY,
|
||||
queries INTEGER NOT NULL,
|
||||
blocked INTEGER NOT NULL,
|
||||
cached INTEGER NOT NULL,
|
||||
rt_sum INTEGER NOT NULL, -- sum(response_time_us) over timed rows
|
||||
rt_count INTEGER NOT NULL -- count(response_time_us)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE bucket_clients (
|
||||
bucket INTEGER NOT NULL,
|
||||
client_ip TEXT NOT NULL,
|
||||
queries INTEGER NOT NULL,
|
||||
PRIMARY KEY (bucket, client_ip)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE bucket_types (
|
||||
bucket INTEGER NOT NULL,
|
||||
qtype INTEGER NOT NULL, -- -1 encodes a NULL qtype, losslessly
|
||||
count INTEGER NOT NULL,
|
||||
PRIMARY KEY (bucket, qtype)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE bucket_routes (
|
||||
bucket INTEGER NOT NULL,
|
||||
route_kind TEXT NOT NULL,
|
||||
source_present INTEGER NOT NULL, -- 0: source NULL; 1: source = source_text
|
||||
source_text TEXT NOT NULL, -- '' when source_present = 0
|
||||
count INTEGER NOT NULL,
|
||||
PRIMARY KEY (bucket, route_kind, source_present, source_text),
|
||||
CHECK (source_present IN (0, 1)),
|
||||
CHECK (source_present = 1 OR source_text = '')
|
||||
) WITHOUT ROWID;
|
||||
```
|
||||
|
||||
Column semantics match the existing aggregates exactly: `blocked` counts
|
||||
`blocked <> 0`; `cached` counts `cache_hit = 1`; routes' `source` is the
|
||||
existing CASE (`upstream` rows → `upstream`, `forward_zone` rows →
|
||||
`forward_zone`, else NULL). The fingerprint moves automatically; do not touch
|
||||
the fingerprint machinery.
|
||||
|
||||
### A.2 Writer maintenance (src/storage/repositories/queries_repo.zig)
|
||||
|
||||
`BatchWriter.writeBatch` updates all four projections inside the same
|
||||
transaction that inserts the raw rows:
|
||||
|
||||
- Aggregate the batch in Zig first, producing per-key deltas; then one UPSERT
|
||||
per touched key:
|
||||
`INSERT ... ON CONFLICT(...) DO UPDATE SET queries = queries + excluded.queries, ...`.
|
||||
The aggregation must accept any slice length — `writeBatch`'s API does not
|
||||
enforce the logger's 100-row batching, so no fixed-size arrays sized to it.
|
||||
`BatchWriter` currently owns no allocator: `init` gains one, owned for the
|
||||
writer's life, used only for the per-batch delta maps; scratch is freed (or
|
||||
a retained map cleared) at the end of every `writeBatch`, and
|
||||
`error.OutOfMemory` fails the batch before the transaction opens — no
|
||||
hidden global allocator, no implicit size cap, no quadratic rescanning.
|
||||
- No per-row SQL, no triggers.
|
||||
- Failure contract: any failed projection statement rolls the whole
|
||||
transaction back — raw rows and projections together — resets every
|
||||
projection statement, and leaves the writer usable for the next batch
|
||||
(`resetAll` discipline as for the raw statements today). Fault-injection
|
||||
acceptance: a batch whose projection update fails leaves the database
|
||||
unchanged, and the next batch succeeds.
|
||||
- The bench measured this at 0.39 ms vs 0.34 ms per batch — acceptance is
|
||||
correctness, not speed.
|
||||
|
||||
### A.3 Retention (src/storage/repositories/queries_repo.zig, prune path)
|
||||
|
||||
In the same transaction as `pruneOlderThan(cutoff)`'s raw delete:
|
||||
|
||||
1. Delete projection rows with `bucket < floor(cutoff / 1800) * 1800` from all
|
||||
four tables.
|
||||
2. If `cutoff` is not on a bucket boundary, recompute the straddling bucket
|
||||
(`floor(cutoff/1800)*1800`) from the remaining raw rows and replace its
|
||||
projection rows in all four tables. Never approximate.
|
||||
|
||||
Failure atomicity: a failure during the projection delete or the
|
||||
straddling-bucket replacement rolls back the raw delete, the watermark
|
||||
advance and every projection change together — one transaction, tested by
|
||||
fault injection.
|
||||
|
||||
Implementation note (Session A, recorded post-build): the bucket_totals
|
||||
recompute carries `HAVING count(*) > 0` — a bare SQL aggregate always yields
|
||||
one row, and an emptied straddling bucket must disappear, not persist as
|
||||
zeros.
|
||||
|
||||
### A.4 Read path (src/storage/repositories/queries_repo.zig)
|
||||
|
||||
One function producing the whole Overview payload for a window, from one
|
||||
already-open read transaction (the caller owns transaction + lock, as today):
|
||||
|
||||
```zig
|
||||
pub const Overview = struct {
|
||||
totals: StatsTotals,
|
||||
buckets: []const Bucket, // bucket_count entries, zero-filled
|
||||
clients: ClientsBreakdown, // top-8 + other, as today
|
||||
types: []const TypeCount, // sorted as stats_types_sql sorts
|
||||
routes: []const RouteCount, // sorted as stats_routes_sql sorts
|
||||
};
|
||||
|
||||
pub fn overview(
|
||||
database: *db.Db,
|
||||
arena: Allocator,
|
||||
since: i64,
|
||||
bucket_seconds: u32,
|
||||
bucket_count: u32,
|
||||
) db.Error!Overview
|
||||
```
|
||||
|
||||
Storage owns these scalars — no import of any web module. `until` is derived
|
||||
as `since + bucket_seconds * bucket_count` with the same overflow checks as
|
||||
`timeseries`. Preconditions, checked before path selection and tested:
|
||||
`bucket_seconds != 0` and `bucket_count != 0` (else `error.Misuse`, matching
|
||||
the existing clients contract); on the projection path
|
||||
(`bucket_seconds >= 1800`) additionally `since` a multiple of 1800 and
|
||||
`bucket_seconds % 1800 == 0`, else `error.Misuse`. The handler's `window()`
|
||||
guarantees all of them.
|
||||
|
||||
Two implementations behind one entry point, chosen by `bucket_seconds`:
|
||||
|
||||
- `bucket_seconds >= 1800` (24h, 7d, 30d): read the four projection tables
|
||||
over `[since, until)`, aggregating 30-min rows up to the serving width in
|
||||
Zig. `distinct_clients` comes from grouping `bucket_clients` by `client_ip`
|
||||
over the window — never from summing per-bucket counts.
|
||||
`avg_response_time_us` = `sum(rt_sum) / sum(rt_count)`, null when
|
||||
`rt_count` sums to 0. Top-8 clients ranked by window total desc, ties by
|
||||
`client_ip` asc (BINARY), residual summed into `other` — identical cut
|
||||
semantics to `statsClients`.
|
||||
- `bucket_seconds < 1800` (1h): one single pass over the raw rows in the
|
||||
window (one SELECT of the needed columns, stepped once), aggregating
|
||||
everything in Zig. Memory bound: O(distinct clients + distinct qtypes +
|
||||
distinct routes) in the window — explicitly permitted; this is a household
|
||||
LAN and the same bound the arena-returning aggregates already carry. This
|
||||
replaces today's five scans and the clients rank+bucket double scan. Same
|
||||
output contracts.
|
||||
|
||||
Sort orders and tie-breaks must reproduce the existing SQL orderings exactly
|
||||
(types: count desc, null last within tie, qtype asc; routes: count desc,
|
||||
route_kind asc, null source last, source asc) — the goldens' byte-stability
|
||||
argument carries over. Note: `RouteKind`'s enum declaration order is not
|
||||
alphabetical; "route_kind asc" means the stored text's byte order, so any Zig
|
||||
comparator orders by `@tagName` bytes, never by enum ordinal (Session A's
|
||||
accumulator already does; mutation-tested).
|
||||
|
||||
### A.5 Acceptance criteria
|
||||
|
||||
- [ ] `zig build test` green.
|
||||
- [ ] Property test: after an arbitrary interleaving of batches and prunes
|
||||
(including a prune cutoff off the bucket grid), every projection table
|
||||
equals a from-scratch recomputation from `query_log`.
|
||||
- [ ] Equivalence test: `overview()` output (both paths) equals a test-only
|
||||
oracle over the same window on the same data — including empty windows,
|
||||
NULL qtype, NULL source on an `upstream` row, ties in ranking, and a
|
||||
window whose last bucket is in progress. The oracle is a copy of the
|
||||
five existing SQL aggregates living in the test file, so it survives
|
||||
Session D's deletion of the production functions.
|
||||
- [ ] Fingerprint test updated (table/index count assertions in
|
||||
querylog_schema tests).
|
||||
|
||||
---
|
||||
|
||||
## Session B: web
|
||||
|
||||
### B.1 Endpoint (src/web/handlers/overview.zig, replacing stats.zig's five)
|
||||
|
||||
`GET /api/overview?period=1h|24h|30d|7d` (same grammar, default 24h, same 400
|
||||
text). One read transaction under `WebState.querylog_lock` covering the
|
||||
aggregate and `coverage.read` — one snapshot, no cross-panel skew. Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"period": "24h", "since": ..., "until": ..., "bucket_seconds": 1800,
|
||||
"totals": { "queries": n, "blocked": n, "clients": n, "avg_response_time_us": n|null },
|
||||
"buckets": [ { "ts": ..., "queries": n, "blocked": n, "cached": n }, ... ],
|
||||
"clients": [ { "client": "ip", "buckets": [n, ...] }, ... ],
|
||||
"other": [n, ...],
|
||||
"types": [ { "qtype": n|null, "count": n }, ... ],
|
||||
"routes": [ { "route": "...", "source": "..."|null, "count": n }, ... ],
|
||||
"coverage": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Field shapes and semantics are exactly today's five bodies merged; `Period`,
|
||||
`window()`, `max_buckets` move to (or stay importable from) the new handler.
|
||||
503 when the query log is unavailable; 500 logging unchanged. Route metadata
|
||||
identical to the removed endpoints: same authentication (`.session`), same
|
||||
rate-limit class (`.counted`), same authority policy (`.read`).
|
||||
|
||||
Remove `GET /api/stats`, `/api/stats/timeseries`, `/api/stats/types`,
|
||||
`/api/stats/routes`, `/api/stats/clients` and their routes.
|
||||
|
||||
Contract surface (B owns all of it): add the new path and schema to the
|
||||
OpenAPI document, remove the five old operations and their schemas, update
|
||||
every drift guard that lists them, add a contract sample for
|
||||
`/api/overview`, and regenerate `admin/src/lib/contractSamples.gen.ts`
|
||||
(reserved for B — Session C must not touch it). Update the API listings in
|
||||
`docs/` and `PLAN.md` that name the five endpoints or the querylog layout.
|
||||
|
||||
### B.2 Response cache (src/web/server.zig WebState + overview.zig)
|
||||
|
||||
Per-period cached response body, invalidated by data change or window roll.
|
||||
Key: `(period, window.until, data_version)` where `data_version` is `PRAGMA
|
||||
data_version` on the web task's connection (it changes when any other
|
||||
connection — logger, retention — commits).
|
||||
|
||||
The entire cache decision happens under `querylog_lock`; nothing touches the
|
||||
shared connection or the slots outside it. Exact sequence per request:
|
||||
|
||||
1. Acquire `querylog_lock` — ONCE. `server.QuerylogRead.open` acquires this
|
||||
lock itself, so the overview handler must not call it after step 1: B
|
||||
refactors the scope into a lock-owning wrapper plus a
|
||||
locked-caller variant (for example `QuerylogRead.openLocked`, documented
|
||||
as requiring the lock), and the overview path uses the locked-caller
|
||||
variant for step 4. A literal "lock, then QuerylogRead.open" deadlocks.
|
||||
2. Sample `PRAGMA data_version` (inside the lock — the shared connection may
|
||||
otherwise have a foreign transaction open, and the slots need the mutual
|
||||
exclusion anyway).
|
||||
3. Hit (`slot.period == period and slot.until == window.until and
|
||||
slot.data_version == sampled`): copy the stored bytes into the request
|
||||
arena, release the lock, respond. The copy is what makes a concurrent
|
||||
rebuild's free-and-replace safe.
|
||||
4. Miss: open the read transaction, build the body, commit. Publish to the
|
||||
slot ONLY after a successful commit, keyed by the version sampled in
|
||||
step 2 (a commit landing during the build bumps `data_version`, so the
|
||||
next request rebuilds — stale-under-new-key is impossible). A failed
|
||||
commit or build publishes nothing and responds 500 as today.
|
||||
5. Copy to the request arena, release the lock, write the socket. The lock
|
||||
never spans a socket write (existing discipline).
|
||||
|
||||
Because the check happens only under the lock, `querylog_lock` is the
|
||||
single-flight: a second request for the same key waits and then hits.
|
||||
|
||||
Storage: one slot per period (4 slots) in `WebState`; body bytes allocated
|
||||
from `WebState.gpa`, replaced on rebuild (free old, install new), freed in
|
||||
`deinit`. No capacity limit beyond the allocator — a body is bounded by the
|
||||
fixed bucket counts plus the household client/type/route cardinality.
|
||||
|
||||
No adaptive polling and no combined-endpoint staging: with projections + this
|
||||
cache a rebuild is ~40 ms x86 / ~0.13 s Pi, so the admin's existing 30 s
|
||||
cadence is fine.
|
||||
|
||||
### B.3 Acceptance criteria
|
||||
|
||||
- [ ] `zig build test` green; handler tests ported from stats.zig (period
|
||||
grammar, window math, one-snapshot behavior) plus: cache hit returns
|
||||
byte-identical body; a logger commit (data_version bump) invalidates;
|
||||
a window roll invalidates; a retention prune committed through another
|
||||
connection invalidates (both the aggregates and the cached
|
||||
`coverage.available_since` are replaced); a failed read-transaction
|
||||
commit neither installs nor replaces a cache entry.
|
||||
- [ ] `curl /api/overview?period=30d` on a seeded scratch instance returns all
|
||||
panels consistent (breakdowns sum to totals on a quiet database).
|
||||
- [ ] The five old routes return 404.
|
||||
|
||||
---
|
||||
|
||||
## Session C: admin
|
||||
|
||||
### C.1 Data layer
|
||||
|
||||
- `admin/src/lib/types.ts`: one `Overview` type mirroring B.1; remove the five
|
||||
per-panel response types.
|
||||
- `admin/src/lib/api.ts`: `getOverview(period)`; remove the five getters.
|
||||
- `admin/src/lib/queries.ts`: `overviewQuery(period)` with
|
||||
`refetchInterval: 30_000` and key `["overview", period]`; remove the five
|
||||
stats query factories and their keys.
|
||||
|
||||
### C.2 Overview page
|
||||
|
||||
`admin/src/features/overview/overviewWindow.ts` and the chart components
|
||||
consume the single query: one `useQuery` where five ran in parallel. Loading,
|
||||
error and coverage handling collapse to one page-level surface (one spinner
|
||||
state, one error state for the whole Overview); the per-panel shells,
|
||||
layout, copy, chart dimensions and accessibility attributes stay exactly as
|
||||
they are. Chart components (TimeseriesChart, ClientChart, Donut) keep their
|
||||
props — adapt the mapping layer, not the charts. C must not touch
|
||||
`contractSamples.gen.ts` (B owns its regeneration).
|
||||
|
||||
### C.3 Acceptance criteria
|
||||
|
||||
- [ ] `npm test` green in admin/ (mock the one endpoint; port the five-query
|
||||
tests).
|
||||
- [ ] `npm run typecheck` green — the build alone does not run tsc. B and C
|
||||
are file-disjoint but type-coupled through `contractSamples.gen.ts`:
|
||||
C's typecheck/test/build gates run (or re-run) AFTER B has regenerated
|
||||
that file. Implementation may proceed in parallel; the green gate is
|
||||
sequenced.
|
||||
- [ ] `npm run build` green; bundle-size assertion still passes.
|
||||
- [ ] Manual: scratch instance renders all Overview panels from the new
|
||||
endpoint on all four periods.
|
||||
|
||||
---
|
||||
|
||||
## Session D: storage cleanup
|
||||
|
||||
After B is merged and green: delete `statsTotals`, `timeseries`, `statsTypes`,
|
||||
`statsRoutes`, `statsClients` and their SQL constants from
|
||||
`queries_repo.zig` — nothing references them once `stats.zig` is gone. The
|
||||
test-only oracle from A.5 stays. Acceptance: `zig build test` green, no dead
|
||||
stats SQL remains in production code.
|
||||
|
||||
---
|
||||
|
||||
## Module layout
|
||||
|
||||
- `src/web/handlers/overview.zig` — new; replaces `src/web/handlers/stats.zig`
|
||||
(deleted by B).
|
||||
- `src/storage/querylog_schema.zig` — projection DDL appended.
|
||||
- `src/storage/repositories/queries_repo.zig` — writer maintenance, retention
|
||||
integration, `overview()` read path (A); five old aggregates deleted (D).
|
||||
- Admin files per C.1/C.2.
|
||||
|
||||
## File ownership
|
||||
|
||||
- A: `src/storage/*` (old aggregates left in place), plus the mechanical
|
||||
allocator-plumbing at every `BatchWriter.init` call site outside storage
|
||||
(`src/web/handlers/*`, `src/web/web_integration_test.zig`, any test using
|
||||
the writer) — A runs before B, so this is sequential, not shared,
|
||||
ownership; A's gate is the full `zig build test`.
|
||||
- B: `src/web/*`, plus: `src/app.zig` (cache cleanup at the composition
|
||||
root), `src/tests.zig` (handler import swap), the OpenAPI document and its
|
||||
drift guards, contract samples including
|
||||
`admin/src/lib/contractSamples.gen.ts`, `docs/**` API listings, `PLAN.md`
|
||||
stale sections, and the Overview/API sections of `specs/ui-redesign.md`
|
||||
(which still mandates the five endpoints and must be amended, not obeyed).
|
||||
- C: `admin/*` EXCEPT `admin/src/lib/contractSamples.gen.ts`.
|
||||
- D: `src/storage/repositories/queries_repo.zig` (sequential, after B).
|
||||
- Orchestrator: `CHANGELOG.md` (hand-written, per release process).
|
||||
B and C run in parallel; the one shared-tree exception above is reserved to B.
|
||||
|
||||
## Acceptance criteria (milestone complete)
|
||||
|
||||
- [ ] `zig build test` and `zig build test -Dintegration` green (the
|
||||
integration suite carries the live route walk, contract-sample
|
||||
comparison, concurrent querylog reads and OpenAPI guards); admin
|
||||
`npm test`/`typecheck`/`build` green.
|
||||
- [ ] Scratch-instance smoke: seeded data + live digs; Overview correct on all
|
||||
periods; old endpoints gone.
|
||||
- [ ] Changelog discloses: schema change resets query history (rename-aside),
|
||||
five endpoints replaced by `/api/overview`.
|
||||
- [ ] Release gate (`zig build cut` fingerprint check) satisfied.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No second database file, no epoch/validity protocol, no ATTACH.
|
||||
- No backfill migration, no rebuild command, no catch-up cursor — projections
|
||||
are born with the file and maintained transactionally; that is the whole
|
||||
coherence story.
|
||||
- No connection pool, no adaptive polling, no DuckDB.
|
||||
- No new indexes on `query_log`, no triggers, no per-row projection SQL.
|
||||
- Do not change the 1h/24h/7d/30d period grammar, bucket widths or counts.
|
||||
- No HTTP-level caching of any kind: no ETag, no `Cache-Control`, no
|
||||
stale-while-revalidate, no background refresh, and never cache a 500/503
|
||||
body. The cache is exactly the in-process design of B.2.
|
||||
- No visual redesign, no chart-prop changes, no cache configuration knobs,
|
||||
no cache metrics. This milestone changes data acquisition and storage only.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user