Compare commits
6
Commits
08d756cc87
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3aa7febb4
|
||
|
|
d2e12ae0e2
|
||
|
|
6a0630c288
|
||
|
|
a261dc2aa3
|
||
|
|
90b85ef954
|
||
|
|
ad26aca198
|
@@ -4,6 +4,39 @@ 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.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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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"
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,31 +66,6 @@ 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;
|
||||
@@ -115,12 +76,13 @@ interface Series {
|
||||
}
|
||||
|
||||
/**
|
||||
* "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 —
|
||||
@@ -131,119 +93,130 @@ function seriesOf(data: StatsClients, names: ClientNames): Series[] {
|
||||
color: seriesColor(clientKey(client.client)),
|
||||
buckets: client.buckets,
|
||||
}));
|
||||
if (data.other.every((count) => count === 0)) return named;
|
||||
return [
|
||||
...named,
|
||||
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
|
||||
];
|
||||
}
|
||||
|
||||
export default function ClientChart({ data }: { data: StatsClients }) {
|
||||
const [containerRef, measuredWidth] = useContainerWidth();
|
||||
/**
|
||||
* 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]));
|
||||
|
||||
const layout = layoutStacked(columns, width, CHART_HEIGHT);
|
||||
const baseline = layout.plot.y + layout.plot.height;
|
||||
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]),
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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)}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
>
|
||||
{compact.format(tick.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={baseline}
|
||||
y2={baseline}
|
||||
{...stylex.props(styles.axisLine)}
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={xScale}
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
<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)}
|
||||
>
|
||||
{(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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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)}>
|
||||
@@ -269,14 +242,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)}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
width={SIZE}
|
||||
height={SIZE}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
{...stylex.props(styles.ring)}
|
||||
>
|
||||
{layout.arcs.map((arc) => (
|
||||
// The stroke is what keeps a shared hue from lying. Colour is a pure
|
||||
// function of identity, so two neighbouring slices can come out the
|
||||
// same; outlined in the panel's own colour they still read as two
|
||||
// shapes rather than merging into one. Attributes rather than a
|
||||
// class, as the client chart's segments are, so the separation is
|
||||
// visible to a test and not only to a stylesheet.
|
||||
<path
|
||||
key={arc.slice.key}
|
||||
d={arc.d}
|
||||
fill={arc.slice.color}
|
||||
fillRule="evenodd"
|
||||
stroke={colors.surfaceRaised}
|
||||
strokeWidth={1}
|
||||
<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}`}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{/* 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.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>
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(hovered.index)}
|
||||
{...sliceAnchor(drawn, hovered.index, total)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<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))}
|
||||
/>
|
||||
{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,133 @@
|
||||
/**
|
||||
* 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 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: {
|
||||
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,
|
||||
},
|
||||
loading: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
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("button", { 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 () => {
|
||||
@@ -259,9 +237,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 +248,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 () => {
|
||||
@@ -379,17 +358,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("button", { 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,59 +114,54 @@ 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
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
/>
|
||||
</div>
|
||||
<OverviewFrame
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
>
|
||||
<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 came out of this one
|
||||
response, so a second copy would only repeat this sentence. */}
|
||||
<CoverageNotice coverage={data.coverage} />
|
||||
|
||||
{/* One notice for the page: every panel is judged against the same window,
|
||||
so a second copy would only repeat this sentence. */}
|
||||
{overview.coverage !== null && <CoverageNotice coverage={overview.coverage} />}
|
||||
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
|
||||
Queries over time
|
||||
</h2>
|
||||
<TimeseriesChart data={data} />
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
|
||||
Queries over time
|
||||
</h2>
|
||||
<PanelBody panel={overview.timeseries}>{(data) => <TimeseriesChart data={data} />}</PanelBody>
|
||||
</section>
|
||||
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
|
||||
Client activity over time
|
||||
</h2>
|
||||
<ClientChart 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>
|
||||
</section>
|
||||
|
||||
<div {...stylex.props(styles.donutRow)}>
|
||||
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
|
||||
Query types
|
||||
</h2>
|
||||
<PanelBody panel={overview.types}>
|
||||
{(data) => <Donut slices={typeSlices(data)} caption="Queries by DNS type" unit="Queries" />}
|
||||
</PanelBody>
|
||||
</section>
|
||||
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
|
||||
Upstream servers
|
||||
</h2>
|
||||
<PanelBody panel={overview.routes}>
|
||||
{(data) => (
|
||||
<Donut
|
||||
slices={routeSlices(data)}
|
||||
caption="Queries by how they were answered"
|
||||
unit="Queries"
|
||||
/>
|
||||
)}
|
||||
</PanelBody>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div {...stylex.props(styles.donutRow)}>
|
||||
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
|
||||
Query types
|
||||
</h2>
|
||||
<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>
|
||||
<Donut
|
||||
slices={routeSlices(data.routes)}
|
||||
caption="Queries by how they were answered"
|
||||
unit="Queries"
|
||||
/>
|
||||
</section>
|
||||
</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)}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
>
|
||||
{compact.format(tick.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={baseline}
|
||||
y2={baseline}
|
||||
{...stylex.props(styles.axisLine)}
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={xScale}
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
{layout.xTicks.map((tick) => (
|
||||
<text
|
||||
key={tick.ts}
|
||||
x={tick.x}
|
||||
y={baseline + 14}
|
||||
textAnchor="middle"
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
>
|
||||
{formatTick(tick.ts, data.bucket_seconds)}
|
||||
</text>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<g key={bar.bucket.ts} opacity={hovered === null || hovered === i ? 1 : 0.55}>
|
||||
{SERIES.map((series) => {
|
||||
const rect = bar.segments[series.key];
|
||||
if (rect.height <= 0) return null;
|
||||
return (
|
||||
<rect
|
||||
key={series.key}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
fill={series.color}
|
||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<rect
|
||||
key={bar.bucket.ts}
|
||||
x={bar.slot.x}
|
||||
y={bar.slot.y}
|
||||
width={bar.slot.width}
|
||||
height={bar.slot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
>
|
||||
<title>{barSummary(bar)}</title>
|
||||
</rect>
|
||||
))}
|
||||
<BarStack<Column, SeriesKey>
|
||||
data={columns}
|
||||
keys={SERIES.map((series) => series.key)}
|
||||
x={(column) => column.ts}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
color={(key) => SERIES_COLOR[key]}
|
||||
>
|
||||
{(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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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=",
|
||||
};
|
||||
|
||||
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 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 },
|
||||
};
|
||||
}
|
||||
|
||||
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 detail =
|
||||
panel.status === "ready"
|
||||
? `${panel.data.period}@${panel.data.until}/${panel.data.coverage.available_since}`
|
||||
: panel.status === "error"
|
||||
? (panel.error as Error).message
|
||||
: "";
|
||||
return <li key={endpoint}>{`${endpoint}:${panel.status}:${detail}`}</li>;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
const panel = useOverviewWindow(period);
|
||||
if (panel.status === "error") lastRetry = panel.retry;
|
||||
const detail =
|
||||
panel.status === "ready"
|
||||
? `${panel.data.period}@${panel.data.until}`
|
||||
: panel.status === "error"
|
||||
? (panel.error as Error).message
|
||||
: "";
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A laggard that stayed behind after its one retry. Not an `ApiError`: nothing
|
||||
* failed, the endpoint simply never caught up, and `InlineError` renders the
|
||||
* message verbatim.
|
||||
*/
|
||||
export const MISMATCH = new Error("This panel is for a different window than the rest of the page. Try again.");
|
||||
|
||||
export function useOverviewWindow(period: Period): OverviewWindow {
|
||||
const queries: { [K in OverviewEndpoint]: UseQueryResult<EndpointBodies[K]> } = {
|
||||
totals: useQuery({ ...statsQuery(period), placeholderData: keepPreviousData }),
|
||||
timeseries: useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData }),
|
||||
clients: useQuery({ ...statsClientsQuery(period), placeholderData: keepPreviousData }),
|
||||
types: useQuery({ ...statsTypesQuery(period), placeholderData: keepPreviousData }),
|
||||
routes: useQuery({ ...statsRoutesQuery(period), placeholderData: keepPreviousData }),
|
||||
};
|
||||
|
||||
// A `keepPreviousData` placeholder for the period just left is a complete,
|
||||
// self-consistent body — and still the wrong one to show under the new label,
|
||||
// so it is neither a candidate for the window nor a member of it.
|
||||
const answers = new Map<OverviewEndpoint, WindowId>();
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
const data = queries[endpoint].data;
|
||||
if (data !== undefined && data.period === period) answers.set(endpoint, windowIdOf(data));
|
||||
}
|
||||
|
||||
let window: WindowId | null = null;
|
||||
for (const id of answers.values()) window = window === null ? id : newerWindow(window, id);
|
||||
|
||||
// The effect below runs on what the responses say, not on how many times they
|
||||
// arrived: a poll that returns byte-identical data must not restart the retry
|
||||
// bookkeeping. The refetchers ride a ref for the same reason — TanStack hands
|
||||
// back a fresh function identity on some renders, and depending on it would
|
||||
// re-enter the effect with nothing changed.
|
||||
const answersKey = OVERVIEW_ENDPOINTS.map((endpoint) => {
|
||||
const id = answers.get(endpoint);
|
||||
return id === undefined ? "" : keyOf(id);
|
||||
}).join("~");
|
||||
const latest = useRef({ answers, refetch: queries });
|
||||
latest.current = { answers, refetch: queries };
|
||||
|
||||
// Which mismatch episode each endpoint has already spent its retry on, keyed
|
||||
// by endpoint and window identity so a new window buys a new attempt.
|
||||
const retriedFor = useRef(new Map<OverviewEndpoint, string>());
|
||||
// Which retry each endpoint is waiting on. Per endpoint, because one shared
|
||||
// counter would let a second endpoint's retry silence the first's completion;
|
||||
// bumped on every retry issued, so a completion from a window or a period the
|
||||
// page has left can neither clear an error the current one reached nor spend
|
||||
// the current window's one retry.
|
||||
const tokens = useRef(new Map<OverviewEndpoint, number>());
|
||||
// State, not a ref: a retry that returns byte-identical data changes nothing
|
||||
// else a render could see, and the panel still has to reach its error.
|
||||
const [landedFor, setLandedFor] = useState(new Map<OverviewEndpoint, string>());
|
||||
|
||||
// Leaving a period ends every episode it opened. A retry issued for the old
|
||||
// period can still be in flight, and without this its completion would land
|
||||
// under the new one holding a token the map still honours: it would record an
|
||||
// episode as spent, so a return to that window would reach the terminal error
|
||||
// without the retry that error is supposed to follow. Bumping the tokens
|
||||
// orphans those answers, and the cleared maps let the new window start clean.
|
||||
const [lastPeriod, setLastPeriod] = useState(period);
|
||||
if (lastPeriod !== period) {
|
||||
setLastPeriod(period);
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
|
||||
}
|
||||
retriedFor.current.clear();
|
||||
setLandedFor(new Map());
|
||||
}
|
||||
|
||||
const windowKey = window === null ? null : keyOf(window);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowKey === null) return;
|
||||
for (const [endpoint, identity] of latest.current.answers) {
|
||||
if (keyOf(identity) === windowKey) {
|
||||
retriedFor.current.delete(endpoint);
|
||||
continue;
|
||||
}
|
||||
const episode = `${endpoint}|${windowKey}`;
|
||||
if (retriedFor.current.get(endpoint) === episode) continue;
|
||||
retriedFor.current.set(endpoint, episode);
|
||||
const token = (tokens.current.get(endpoint) ?? 0) + 1;
|
||||
tokens.current.set(endpoint, token);
|
||||
const landed = () => {
|
||||
if (tokens.current.get(endpoint) !== token) return;
|
||||
setLandedFor((previous) => new Map(previous).set(endpoint, episode));
|
||||
};
|
||||
void latest.current.refetch[endpoint].refetch().then(landed, landed);
|
||||
}
|
||||
}, [answersKey, windowKey]);
|
||||
|
||||
const retry = useCallback((endpoint: OverviewEndpoint) => {
|
||||
retriedFor.current.delete(endpoint);
|
||||
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
|
||||
setLandedFor((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.delete(endpoint);
|
||||
return next;
|
||||
});
|
||||
void latest.current.refetch[endpoint].refetch();
|
||||
}, []);
|
||||
|
||||
function panelOf<K extends OverviewEndpoint>(endpoint: K): Panel<EndpointBodies[K]> {
|
||||
const query = queries[endpoint];
|
||||
const onRetry = () => retry(endpoint);
|
||||
if (query.isError) return { status: "error", error: query.error, retry: onRetry };
|
||||
const data = query.data;
|
||||
if (
|
||||
data !== undefined &&
|
||||
windowKey !== null &&
|
||||
data.period === period &&
|
||||
keyOf(windowIdOf(data)) === windowKey
|
||||
) {
|
||||
return { status: "ready", data };
|
||||
}
|
||||
if (windowKey !== null && landedFor.get(endpoint) === `${endpoint}|${windowKey}`) {
|
||||
return { status: "error", error: MISMATCH, retry: onRetry };
|
||||
}
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
const panels = {
|
||||
totals: panelOf("totals"),
|
||||
timeseries: panelOf("timeseries"),
|
||||
clients: panelOf("clients"),
|
||||
types: panelOf("types"),
|
||||
routes: panelOf("routes"),
|
||||
};
|
||||
|
||||
// The notice describes the window, so any member of it can supply the
|
||||
// watermark: whichever panel arrived says the same thing about coverage.
|
||||
let coverage: Coverage | null = null;
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
const panel = panels[endpoint];
|
||||
if (panel.status === "ready") {
|
||||
coverage = panel.data.coverage;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { window, coverage, ...panels };
|
||||
export function useOverviewWindow(period: Period): Panel<Overview> {
|
||||
const query = useQuery({ ...overviewQuery(period), placeholderData: keepPreviousData });
|
||||
const { refetch } = query;
|
||||
const retry = useCallback(() => void refetch(), [refetch]);
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -837,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: [
|
||||
{
|
||||
@@ -906,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,
|
||||
});
|
||||
|
||||
|
||||
+20
-36
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+15
-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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
.{
|
||||
.name = .nxdns,
|
||||
.version = "0.0.10",
|
||||
.version = "0.0.12",
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x3307b311dded1d91,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
@@ -0,0 +1,337 @@
|
||||
# Milestone 37: Upstream failover budget — deadline ownership and honest attribution
|
||||
|
||||
Make the pool's failover loop own one deadline (admission included), revive the
|
||||
standby under primary saturation, and stop blaming endpoints for budget
|
||||
exhaustion.
|
||||
|
||||
## The defect (field-verified on the Pi, 0.0.11)
|
||||
|
||||
With defaults attempt=2500 ms / total=5000 ms and two upstreams:
|
||||
|
||||
1. `Pool.exchangeLoopLen` (pool.zig:277) never computes remaining time. The
|
||||
semaphore wait consumes the total budget invisibly: under a traffic burst
|
||||
the primary's slots saturate, a new request queues behind them while the
|
||||
standby sits idle with free slots, and the outer `raceWithin(total)` cancels
|
||||
whatever finally runs. 155 requests died at the 5 s cap; 706 of 717
|
||||
SERVFAILs came from one bursty client. On an idle pool a fast standby works
|
||||
today — the queue is the killer, not the timer arithmetic alone.
|
||||
2. The reporting contradicts itself. `selected` is written before the attempt
|
||||
runs (pool.zig:348), so the query log blamed the standby on 279 rows;
|
||||
cancelled attempts are excluded from health (pool.zig:363), so its counters
|
||||
read 0/0. Nothing records "the request exhausted its own budget."
|
||||
|
||||
Design reviewed and converged with Codex (thread of 2026-08-27). Defaults do
|
||||
not change.
|
||||
|
||||
## Sessions
|
||||
|
||||
Three, strictly sequential: A (transport vocabulary) → B (pool) → C
|
||||
(handler, forward client, surfaces).
|
||||
|
||||
---
|
||||
|
||||
## Session A: transport vocabulary
|
||||
|
||||
### A.1 `error.BudgetExhausted` and a fourth group (src/upstream/transport.zig)
|
||||
|
||||
- Add `BudgetExhausted` to `ExchangeError`.
|
||||
- Add `.budget_exhausted` to `Group`; `group()` maps the new error to it. The
|
||||
switch stays exhaustive with no `else` — every switch over `Group` breaks at
|
||||
compile time until it handles the new group, which is the point. The
|
||||
production switches are `pool.zig` (:353), `handler.zig` (:677, :735) and
|
||||
`forward_client.zig` (:124). Session A owns a mechanical placeholder arm at
|
||||
each (pool and forward_client: propagate the error without recording
|
||||
health; handler: `=> ctx.servFail()`), plus any test switches the compiler
|
||||
flags, so A's `zig build test` passes. B and C then own the real behavior
|
||||
at their sites. (health.zig has no `group` call; the doh/dot occurrences
|
||||
are tests, not switches.)
|
||||
|
||||
### A.2 Timer-origin race (src/upstream/transport.zig)
|
||||
|
||||
`raceWithin` collapses "the expiry task won" and "the raced operation itself
|
||||
returned error.Timeout" into one `error.Timeout`. Add:
|
||||
|
||||
```zig
|
||||
pub const RaceOutcome = enum { completed, expired };
|
||||
|
||||
pub fn raceUntilTagged(
|
||||
io: std.Io,
|
||||
expiry_at: std.Io.Clock.Timestamp,
|
||||
outcome: *RaceOutcome,
|
||||
comptime f: anytype,
|
||||
args: anytype,
|
||||
) ExchangeError!RacedPayload(f)
|
||||
```
|
||||
|
||||
The expiry parameter is an ABSOLUTE timestamp, not a duration: a duration
|
||||
computed from `deadline.toDurationFromNow()` and then slept re-anchors at
|
||||
"now", drifting past the total deadline and misclassifying a nominally full
|
||||
attempt. The pool computes `expiry_at = min(now + attempt, total_deadline)`
|
||||
and passes the timestamp. On the expiry side the function sets `outcome.* =
|
||||
.expired` and returns `error.Timeout`; on completion it sets `.completed` and
|
||||
returns the raced result (which may itself be `error.Timeout` from the leaf —
|
||||
that is a completed peer timeout, not an expiry). `raceWithin` keeps its
|
||||
public API and behavior but delegates to the same internal harness (compute
|
||||
the absolute deadline, discard the outcome) — one `Select` harness in the
|
||||
file, not two copies.
|
||||
|
||||
### A.3 Acceptance
|
||||
|
||||
- [ ] `zig build test` green.
|
||||
- [ ] Unit tests: tagged race distinguishes leaf `error.Timeout` (completed)
|
||||
from expiry (`expired`); the untagged wrapper is unchanged behavior.
|
||||
|
||||
---
|
||||
|
||||
## Session B: the pool (src/upstream/pool.zig)
|
||||
|
||||
### B.1 One deadline owns the loop
|
||||
|
||||
- `Pool.exchange` establishes `deadline = std.Io.Timeout{ .duration =
|
||||
self.timeouts.total }.toDeadline(io)` (`.awake` clock, as the loop uses
|
||||
today) and passes it down. The equal-deadline outer `raceWithin` at
|
||||
pool.zig:267 is REMOVED — the loop owns the deadline; two timers aimed at
|
||||
the same instant race each other and let the outer cancellation bypass the
|
||||
loop's classification. No replacement watchdog (the outer race never bounded
|
||||
uncancelable health writes anyway; a later-firing watchdog is scope creep).
|
||||
- Every blocking step consumes the deadline:
|
||||
- Admission: through the ownership-safe protocol of B.2 — never a bare
|
||||
`Semaphore.wait` raced against an expiry. Racing the wait with
|
||||
`Select.cancelDiscard` can leak a permit: the wait may have decremented
|
||||
the count in the same instant the expiry wins, and the discarded success
|
||||
never reaches the caller's release-defer. If no time remains before
|
||||
admission, return `error.BudgetExhausted` without waiting.
|
||||
- Attempt: raced via `raceUntilTagged` against
|
||||
`expiry_at = min(now + timeouts.attempt, total_deadline)`.
|
||||
- `total < attempt` is already rejected by validate.zig; `total == attempt`
|
||||
(and any admission overhead) simply yields truncated attempts, handled by
|
||||
B.3.
|
||||
|
||||
### B.2 Admission without head-of-line blocking
|
||||
|
||||
Zig 0.16's `Semaphore` has neither try-acquire nor a timed wait, so the pool
|
||||
gains a local admission helper mirroring the standard semaphore's own
|
||||
mutex/decrement/condition protocol (never by patching `../zig`, never by
|
||||
spinning):
|
||||
|
||||
- `tryAcquire()` — take a permit if one is immediately available, else fail
|
||||
without blocking.
|
||||
- `acquireUntil(deadline)` — timed acquisition; returns Acquired (holding a
|
||||
permit), Expired (holding none), or `error.Canceled` (holding none).
|
||||
`std.Io.Condition` has no timed wait in 0.16, so this is a NEW pool-local
|
||||
primitive, specified exactly: state lives under one mutex (permit count +
|
||||
waiter bookkeeping); the wait itself may race `Condition.wait` against a
|
||||
sleep via `Select`, because a permit is only ever taken under the mutex
|
||||
AFTER the race resolves — a discarded wake is a lost notification, not a
|
||||
lost permit. To keep that lost notification from stranding another waiter,
|
||||
an exiting waiter that may have absorbed a signal (expiry or cancellation
|
||||
path) re-signals the condition before returning. Permit conservation is by
|
||||
construction: the decrement and the "did I win" decision happen under the
|
||||
same mutex. Every take is non-blocking (the mutex's `tryLock`): a lock
|
||||
miss reads as "no permit now", and `acquireUntil` re-enters the
|
||||
absolute-deadline race on each miss, so contention on the admission mutex
|
||||
never carries a call past its deadline (review round 2026-08-27). Tests:
|
||||
an expiry/acquisition tie leaves the permit count exact; a cancelled
|
||||
waiter holds nothing and a peer waiter still wakes; a contended admission
|
||||
mutex does not carry `acquireUntil` past its deadline.
|
||||
|
||||
Loop semantics — priority means ordering among immediately admissible
|
||||
candidates:
|
||||
|
||||
1. Pass one, first sweep in priority order: `tryAcquire` on each available
|
||||
entry; the first immediate success is attempted. A saturated entry is
|
||||
skipped while another eligible entry has capacity.
|
||||
2. If the attempted entry fails (peer fault), the sweep continues from the
|
||||
next entry, still by `tryAcquire`; previously skipped saturated entries
|
||||
are re-tried by `tryAcquire` on each subsequent sweep step (a slot may
|
||||
have freed).
|
||||
3. Only when no eligible entry has an immediate permit does the loop block:
|
||||
`acquireUntil(remaining deadline)` on the highest-priority eligible
|
||||
entry. Expired → `error.BudgetExhausted`. The no-head-of-line guarantee
|
||||
is deliberately scoped to capacity observed during the sweep: once
|
||||
blocked, a lower-priority slot freeing does not wake this waiter
|
||||
(any-entry wakeups need multi-wait machinery this milestone does not
|
||||
buy). Record this bound in the admission helper's doc comment.
|
||||
4. Pass two (backoff probing) keeps today's in-order probing but admits
|
||||
through the same helper bounded by the remaining deadline — it
|
||||
deliberately retains blocking, one entry at a time, because probing a
|
||||
backed-off entry is already a last resort.
|
||||
5. The post-admission health recheck survives the refactor: after acquiring
|
||||
a permit by either path in pass one, re-read health against a fresh
|
||||
`now`; an entry that entered backoff while this task waited is released
|
||||
(permit returned) and the sweep resumes. The existing regression test
|
||||
for this recheck is retained.
|
||||
6. Before any attempt starts — immediate admission included — the loop
|
||||
re-reads the clock; a deadline already passed returns
|
||||
`error.BudgetExhausted` with the permit returned and no endpoint named,
|
||||
so an instantly-completing leaf can never manufacture evidence after
|
||||
exhaustion (review round 2026-08-27). Test: an exchange whose deadline
|
||||
is already gone starts no attempt.
|
||||
7. The compiled pool bound (`Pool.max_entries`, 64) is enforced at config
|
||||
validation: more than 64 ENABLED upstreams is `TooManyUpstreams` at path
|
||||
`upstreams`, so a valid config can never trip the pool assert (review
|
||||
round 2026-08-27). Tests: 64 enabled passes, 65 fails, 65 listed with
|
||||
64 disabled passes.
|
||||
8. Queue accounting keeps its meaning: `queued_total` and
|
||||
`queued_seconds_total` count only the blocking `acquireUntil` path,
|
||||
recorded whether it ends in acquisition, expiry or cancellation; a
|
||||
`tryAcquire` — hit or miss — never counts as queued. Acceptance test
|
||||
retained.
|
||||
|
||||
### B.3 Classification (uses A.2's tagged race)
|
||||
|
||||
| Attempt outcome | Budget it ran with | Health | `selected` | Loop action |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| success | any | success | this endpoint | return answer |
|
||||
| expiry (`expired`) | full `attempt` | failure | this endpoint | continue failover |
|
||||
| expiry (`expired`) | truncated | untouched | unchanged | return `error.BudgetExhausted` |
|
||||
| completed peer fault (incl. leaf Timeout) | any, even truncated | failure | this endpoint | continue failover |
|
||||
| local_resource | — | untouched | unchanged | return err (as today) |
|
||||
| cancellation | — | untouched | unchanged | return `error.Canceled` |
|
||||
| wait exhausts deadline | — | untouched | unchanged (null if nothing ran) | return `error.BudgetExhausted` |
|
||||
| completed attempt returns `error.BudgetExhausted` (a leaf may emit it once it exists) | any | untouched | unchanged | return it; counter increments once at the outer pool |
|
||||
|
||||
A truncated expiry is a censored observation: the pool did not grant the
|
||||
configured observation interval, so it is evidence about the pool's budget,
|
||||
never about the peer. No minimum-attempt floor exists.
|
||||
|
||||
### B.4 `selected` = last attributable endpoint
|
||||
|
||||
Assign `selected.*` only in the success row and the two health-recording
|
||||
failure rows above — after the attempt completes, not before it starts. This
|
||||
attribution rule is POOL-SPECIFIC: leaf clients (DoH, DoT, ForwardClient,
|
||||
test fakes) keep their write-before-attempt behavior — they have one
|
||||
endpoint and record no health, so "the endpoint I tried" is honest there.
|
||||
The `Client.exchange` doc comment in transport.zig is amended to state both
|
||||
contracts: implementations may write before each attempt; `Pool` documents
|
||||
its stricter last-attributable rule on `Pool.exchange` itself. The pool-level
|
||||
tests at pool.zig:748-771 (selected-before-attempt) and :910 invert into the
|
||||
new contract's tests; leaf-client tests are untouched.
|
||||
|
||||
### B.5 The counter
|
||||
|
||||
`Pool` gains one pool-level counter, `budget_exhausted_total` (atomic u64,
|
||||
incremented once per exchange that returns `error.BudgetExhausted`, never per
|
||||
endpoint). `Pool.snapshot` returns per-entry rows and cannot carry a
|
||||
pool-wide number without duplicating it — so the counter is exposed through a
|
||||
separate getter, `Pool.budgetExhaustedTotal()`, and B owns the mechanical
|
||||
plumbing at every existing snapshot call site its change touches so B's own
|
||||
gate passes before C. No per-stage split (queue vs attempt) —
|
||||
fixed-cardinality stage labels are deferred until an operator needs them.
|
||||
|
||||
### B.6 Acceptance (the decisive regressions first)
|
||||
|
||||
- [ ] Fast standby at shipped defaults: entry 0 stalls its full attempt
|
||||
budget, entry 1 answers instantly, attempt = total/2 → entry 1 answers.
|
||||
- [ ] Saturated primary, free standby: entry 0's slots all held by stalled
|
||||
exchanges, entry 1 free and fast → entry 1 answers well inside the
|
||||
deadline. This is the Pi reproduction; it must FAIL against the current
|
||||
code and pass after B.2.
|
||||
- [ ] Truncated expiry mutates no health, returns BudgetExhausted, leaves
|
||||
`selected` at the last attributable endpoint.
|
||||
- [ ] Truncated attempt failing with ConnectionRefused mutates health and
|
||||
updates `selected`.
|
||||
- [ ] Queue-only exhaustion → BudgetExhausted with `selected == null`.
|
||||
- [ ] All-backoff pass two runs under the same deadline.
|
||||
- [ ] External cancellation returns Canceled, counts no budget, mutates
|
||||
nothing.
|
||||
- [ ] `budget_exhausted_total` increments once per exhausted exchange.
|
||||
- [ ] Existing invariants hold: cancelled waiter returns its permit; two
|
||||
stalling upstreams cost ≤ total, not one budget each.
|
||||
|
||||
---
|
||||
|
||||
## Session C: handler, forward client, surfaces
|
||||
|
||||
### C.1 Handler (src/server/handler.zig)
|
||||
|
||||
Both `transport.group` switches (:677, :735) gain `.budget_exhausted =>
|
||||
ctx.servFail()` — SERVFAIL on the wire, same as peer faults; no rcode fits
|
||||
better. No per-query warn log (spam); the counter is the record.
|
||||
|
||||
### C.2 Forward client (src/local/forward_client.zig)
|
||||
|
||||
One outer budget for the whole exchange: wrap UDP attempt → truncation
|
||||
fallback → TCP in a single tagged race against `read_timeout` (the UDP
|
||||
receive's internal deadline and the TCP `raceWithin` at :217 collapse into
|
||||
the one outer bound — remove the fresh TCP budget). Every timeout here
|
||||
concerns the single configured resolver, so expiry stays `error.Timeout`
|
||||
(peer evidence), never BudgetExhausted — the budget/peer distinction is pool
|
||||
policy. Test: truncated-UDP-then-stalled-TCP completes or expires within one
|
||||
`read_timeout`, not two. Update the doc comment on `read_timeout_ms` in the
|
||||
config (validate.zig:360 note and the settings description) to say it bounds
|
||||
the whole forward-zone exchange. The key is NOT renamed (anti-requirement).
|
||||
|
||||
### C.3 Surfaces
|
||||
|
||||
- Metrics: `nxdns_upstream_budget_exhausted_total` (pool-wide) wherever
|
||||
`nxdns_upstream_*` counters render, read via `Pool.budgetExhaustedTotal()`.
|
||||
- `/api/health` is NOT changed (decision, not omission): the counter is an
|
||||
operator metric, it never changes health status, and adding it to the API
|
||||
would drag openapi.yaml, admin types, fixtures, contract samples and the
|
||||
admin gates into a milestone that owes them nothing. Metrics only.
|
||||
- docs: `docs/reference/configuration.md` — every statement describing
|
||||
`read_timeout_ms` as a per-read or per-attempt bound is rewritten to the
|
||||
whole-exchange contract; the upstream timeouts section gains one paragraph
|
||||
on budget semantics (deadline, truncation, attribution). The stale contract
|
||||
text in `src/config/model.zig` (the setting's doc comment) and the note at
|
||||
`src/config/validate.zig:360` are updated to match.
|
||||
|
||||
### C.4 Acceptance
|
||||
|
||||
- [ ] `zig build test` and `zig build test -Dintegration` green.
|
||||
- [ ] Metrics test covers the new counter's rendering.
|
||||
- [ ] Forward-client single-budget test per C.2.
|
||||
|
||||
---
|
||||
|
||||
## File ownership
|
||||
|
||||
- A: `src/upstream/transport.zig`, plus mechanical placeholder arms at the
|
||||
broken `Group` switches (`src/upstream/pool.zig`, `src/server/handler.zig`,
|
||||
`src/local/forward_client.zig`, test switches the compiler flags) —
|
||||
sequential ownership, A runs alone; C takes forward_client.zig and
|
||||
handler.zig over later in sequence.
|
||||
- B: `src/upstream/pool.zig` (+ the `Client.exchange` doc contract in
|
||||
transport.zig and snapshot-caller plumbing for the new getter — B runs
|
||||
alone after A).
|
||||
- C: `src/server/handler.zig`, `src/local/forward_client.zig`, the metrics
|
||||
rendering files, `src/config/model.zig` + `src/config/validate.zig` doc
|
||||
text, `docs/reference/configuration.md`.
|
||||
- Orchestrator: CHANGELOG.md.
|
||||
|
||||
## Acceptance criteria (milestone complete)
|
||||
|
||||
- [ ] All session criteria; both suites green; `zig fmt --check` clean.
|
||||
- [x] The saturated-primary regression demonstrably fails on pre-milestone
|
||||
code and passes after. Evidence (orchestrator-run mutation check,
|
||||
2026-08-27): with the pass-one `tryAcquire` sweep disabled in
|
||||
`Pool.admit` — restoring pre-fix head-of-line blocking — the test
|
||||
"a saturated primary defers to a standby that has capacity" fails with
|
||||
`error.BudgetExhausted` out of the blocking admission path, and four
|
||||
queue-accounting/attribution tests fail with it (5 failed, seed
|
||||
0x91ce5df5). Sweep restored: 30/30 steps, 1892/2067 passed, 0 failed.
|
||||
- [ ] Changelog: failover now works under primary saturation; SERVFAILs from
|
||||
budget exhaustion are counted, not blamed on an upstream; for
|
||||
upstream-pool queries the query-log `upstream` field now names only
|
||||
endpoints whose outcome was recorded (may be null) — forward-zone
|
||||
queries keep naming their single configured resolver as before.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No default timeout changes.
|
||||
- No parallel/racing fan-out to multiple upstreams. Priority failover stays,
|
||||
with priority defined as ordering among immediately admissible candidates
|
||||
(B.2) — a saturated higher-priority entry defers to an admissible
|
||||
lower-priority one; it does not outrank an idle standby by blocking on it.
|
||||
- No `/api/health` or admin changes; the counter surfaces in metrics only.
|
||||
- No patching of the vendored/system Zig stdlib; the admission helper is
|
||||
pool-local.
|
||||
- No rename of `upstream.read_timeout_ms` (doc fix only).
|
||||
- No minimum-attempt floor constant.
|
||||
- No per-stage split of the budget counter; no per-query budget log lines.
|
||||
- No watchdog replacing the removed outer race.
|
||||
- No fake upstream identity (e.g. "budget") in the query log; no new
|
||||
query-log column.
|
||||
- No changes to backoff policy, slot counts, or health scoring beyond the
|
||||
attribution rules above.
|
||||
@@ -54,14 +54,14 @@ One question, answered over a period the reader chooses: what did the resolver d
|
||||
|
||||
Top to bottom, edge to edge:
|
||||
|
||||
1. **Four stat tiles**, neutral chrome throughout — no coloured accents; emphasis is typographic. Queries, Blocked (count and rate), Clients, Average response. Each tile carries the way into the rows behind its number: Queries and Blocked open Activity for exactly the bounds the stats response returned, Clients opens the clients page, and Average response has nothing to open.
|
||||
1. **Four stat tiles**, neutral chrome throughout — no coloured accents; emphasis is typographic. Queries, Blocked (count and rate), Clients, Average response. Each tile carries the way into the rows behind its number: Queries and Blocked open Activity for exactly the bounds the overview response returned, Clients opens the clients page, and Average response has nothing to open.
|
||||
2. **Queries over time** — the existing query-volume timeline, split blocked/cached/other, full width.
|
||||
3. **Client activity over time** — one stacked series per named client plus "other", on the same bucket alignment as the timeline so the two charts share an x-axis. A client registered under a name is labelled by it, with the same precedence the query tables apply and the address kept as the title; colour keys on the address, so naming a client never repaints its series.
|
||||
4. **Query types** and **Upstream servers** — two donuts, side by side above 1280px and stacked below, with the ring and its legend centred in the panel while stacked and left-anchored once they are a pair. Types are labelled by the admin's own `qtypeName()`; routes by route-kind labels and by the answering resolver or zone. Each donut's SVG is decoration (`aria-hidden`, `focusable="false"`); a visible legend and a visually hidden table are the accessible surface. An empty window says "No queries in this period." rather than drawing nothing.
|
||||
|
||||
Colours key on semantic identity — the qtype value, the client string, the `(route, source)` pair — so a rank change between two polls never repaints an entry. Charts stay lightweight SVG; no charting dependency.
|
||||
|
||||
**Window coherence, five requests.** Totals, timeseries, clients, types and routes are separate calls, and the page holds one window identified by `(period, since, until, coverage.available_since)` — the watermark joins the identity because retention advancing mid-page changes what the same span can answer for. A response is a member only if all four fields match. Rendering is per panel: a member renders, a panel still in flight shows its own loading state, a panel whose request failed shows its own error and Retry, and the members keep rendering throughout — a failed donut never blanks the charts. A response behind the window is refetched once per endpoint-keyed episode and, if it stays behind, that panel alone shows an error. This is window coherence, not data-snapshot coherence: live inserts between requests may shift counts slightly between panels, and that is accepted. One coverage notice for the page, from the window's watermark.
|
||||
**One request, one snapshot (superseded 2026-08-27 by milestone 36; the paragraph below replaces the original five-request window-coherence design).** The page makes one call, `GET /api/overview?period=…`, whose body carries totals, timeseries, clients, types, routes and coverage from a single read transaction — data-snapshot coherence, so the panels cannot disagree and no reconciliation layer exists. Loading and error are page-level: one loading surface, one error with Retry for the whole Overview. The per-panel shells, layout, copy and accessibility surfaces are unchanged. One coverage notice for the page, from the response's watermark. A `keepPreviousData` body whose own `period` is not the selected one keeps the page in loading.
|
||||
|
||||
**The shell.** The header carries no protection display at all. The Pause/Resume control sits at the foot of the sidebar, above the version label, in both the desktop rail and the mobile drawer; it is the only global runtime action, and it belongs to the resolver rather than to any page. It still appears beside the detail of a query that was blocked. The control states a pause with itself — "Paused until 14:05", or "Paused" when the pause has no end — because "Resume" names an action without naming the state it would end, and with the indicator and the status rows both gone the sidebar is the only place a page other than Diagnostics can carry that fact. An active resolver gets no line; the button says Pause, which is the whole message. The line and the health strip read one `protection` condition through one clock format, so they cannot disagree. The Diagnostics navigation item carries a badge: the open-episode count, or a neutral "!" when the rollup is degraded with nothing open and when the latest health poll failed — an unknown must never read as healthy. It is hidden only when health data exists, the latest poll succeeded, and the rollup is ok with nothing open.
|
||||
|
||||
@@ -168,7 +168,7 @@ No response payloads, answer RR sets, EDNS data or packet bytes are stored. The
|
||||
|
||||
Privacy transforms apply to every new domain-bearing field, not only `domain`: with `hide_domains` on, matched names, CNAME targets and safe-search targets hide consistently.
|
||||
|
||||
A one-row `querylog_meta (created_at INTEGER NOT NULL)` table lets the stats and query APIs return a conservative `available_since`, which distinguishes "zero queries" from "history does not exist".
|
||||
A one-row `querylog_meta (created_at INTEGER NOT NULL)` table lets the overview and query APIs return a conservative `available_since`, which distinguishes "zero queries" from "history does not exist".
|
||||
|
||||
### Historical query detail
|
||||
|
||||
@@ -208,9 +208,9 @@ Database mode uses the same information architecture with real edit actions, plu
|
||||
|
||||
`GET /api/queries` keeps keyset pagination and its filters; rows gain `rcode`, `route_kind`, `policy_action` and the short policy reason the table needs, and the body gains `coverage: {complete, available_since}`. `GET /api/queries/{id}` returns nested `request` / `policy` / `route` / `response` provenance. `GET /api/queries/live` sends the same object without `id`.
|
||||
|
||||
`GET /api/stats` and `/api/stats/timeseries` add `complete` and `available_since`.
|
||||
**Superseded by milestone 36 (2026-08-27).** This section originally specified five per-panel endpoints — `GET /api/stats`, `/api/stats/timeseries`, `/api/stats/types`, `/api/stats/routes` and `/api/stats/clients`. Five requests could promise a shared window but never a shared snapshot, and each one scanned every raw row in it. They are replaced by a single `GET /api/overview?period=1h|24h|7d|30d`, which returns `{period, since, until, bucket_seconds, totals:{queries, blocked, clients, avg_response_time_us}, buckets:[{ts, queries, blocked, cached}], clients:[{client, buckets}], other, types:[{qtype, count}], routes:[{route, source, count}], coverage:{complete, available_since}}` — every field with the semantics the five bodies gave it, over one deferred SQLite read transaction, so the breakdowns and the coverage watermark describe one database state. The 24h, 7d and 30d windows are served from 30-minute projection tables maintained transactionally beside the raw rows; the 1h window takes one raw scan. Per-period response caching keyed on `(window.until, PRAGMA data_version)` lives in the web layer.
|
||||
|
||||
**Three period aggregations (added 2026-08-22)** to feed the new Overview panels, all taking the same `period` parameter and reporting over the same aligned window, and all reading their rows and their coverage watermark inside one deferred SQLite read transaction. `GET /api/stats/types` → `{period, since, until, coverage, types:[{qtype, count}]}`, the numeric type only — naming types stays the admin's job, and a second table in the server would drift out of agreement with it — with the rows that recorded no type kept as their own `null` group. `GET /api/stats/routes` → `{period, since, until, coverage, routes:[{route, source, count}]}`, grouping `upstream` rows by the answering resolver and `forward_zone` rows by the zone, with blocked, cache, local and rejected carrying no source. `GET /api/stats/clients` → `{period, since, until, bucket_seconds, coverage, clients:[{client, buckets}], other}`, bucketed exactly as `/api/stats/timeseries`, the eight busiest clients named and everything else summed into `other`, which is always present and always bucket-count-sized. No new writers and no new state: all three are pure reads over the query log's provenance columns.
|
||||
The panel semantics the five endpoints defined all carry over unchanged: types are the numeric type only — naming types stays the admin's job, and a second table in the server would drift out of agreement with it — with the rows that recorded no type kept as their own `null` group; routes group `upstream` rows by the answering resolver and `forward_zone` rows by the zone, with blocked, cache, local and rejected carrying no source; clients name the eight busiest and sum everything else into `other`, which is always present and always bucket-count-sized.
|
||||
|
||||
Existing mutation endpoints stay specific. Diagnostics introduces no generic "perform remediation" endpoint; it invokes the existing blocklist-refresh and certificate-reload operations.
|
||||
|
||||
|
||||
@@ -817,6 +817,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// Same argument as the live hash: a settings PUT may have installed an
|
||||
// owned generation, and this runs after `group.cancel`.
|
||||
defer web_state.proxies.deinit(gpa);
|
||||
// The Overview response cache owns its bodies from `gpa`. Same argument
|
||||
// again: no web task can still be reading a slot once the group is cancelled.
|
||||
defer web_state.overview_cache.deinit(gpa);
|
||||
if (cfg.web.enabled) web_state = .{
|
||||
.gpa = gpa,
|
||||
.web = cfg.web,
|
||||
|
||||
+1
-1
@@ -1012,7 +1012,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
|
||||
.priority = server.priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.sem = .{ .permits = slots.len },
|
||||
.admission = .{ .permits = slots.len },
|
||||
.reuse_recoveries = &recoveries,
|
||||
}};
|
||||
var single: pool.Pool = .init(&entries, .{}, timeouts, seed);
|
||||
|
||||
@@ -57,9 +57,11 @@ pub const Config = struct {
|
||||
pub const Upstream = struct {
|
||||
/// Bounds one attempt against one upstream inside the pool's failover loop.
|
||||
attempt_timeout_ms: u32 = 2500,
|
||||
/// The forward-zone client's read deadline, and nothing else. It bounds a
|
||||
/// different subsystem from the two above (`src/local/forward_client.zig`),
|
||||
/// so no cross-check relates it to them.
|
||||
/// The forward-zone client's whole-exchange budget, and nothing else: one
|
||||
/// bound covers the UDP attempt, a TC=1 fallback and the TCP retry
|
||||
/// together, not each of them. It bounds a different subsystem from the two
|
||||
/// above (`src/local/forward_client.zig`), so no cross-check relates it to
|
||||
/// them.
|
||||
read_timeout_ms: u32 = 3000,
|
||||
/// The whole-exchange budget: every failover attempt together, not one of
|
||||
/// them. The pool races the entire loop against it.
|
||||
|
||||
+66
-2
@@ -52,6 +52,7 @@ const limits = @import("limits.zig");
|
||||
const logger = @import("../storage/logger.zig");
|
||||
const regex = @import("../filter/regex.zig");
|
||||
const safe_url = @import("../safe_url.zig");
|
||||
const pool = @import("../upstream/pool.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const Config = model.Config;
|
||||
@@ -69,6 +70,7 @@ const Prefix = address.Prefix;
|
||||
/// like every other resource failure.
|
||||
pub const ValidateError = error{
|
||||
NoUpstreams,
|
||||
TooManyUpstreams,
|
||||
BadUpstreamUrl,
|
||||
UpstreamHostNotIpLiteral,
|
||||
DuplicateUpstreamUrl,
|
||||
@@ -357,8 +359,9 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
// The only cross-check that relates two knobs of one subsystem: the pool
|
||||
// races one attempt against `attempt` and the whole failover loop against
|
||||
// `total`, so an attempt budget above the total one can never be reached.
|
||||
// `read_timeout_ms` belongs to the forward-zone client and is deliberately
|
||||
// unrelated to both.
|
||||
// `read_timeout_ms` bounds the forward-zone client's whole exchange —
|
||||
// UDP attempt, TC=1 fallback and TCP retry under one budget — and is
|
||||
// deliberately unrelated to both.
|
||||
if (up.attempt_timeout_ms > up.total_timeout_ms) {
|
||||
try diags.add(
|
||||
error.BadTimeout,
|
||||
@@ -823,6 +826,21 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
|
||||
.{},
|
||||
);
|
||||
}
|
||||
// Each enabled upstream becomes one pool entry, and the failover loop
|
||||
// tracks the entries it has spent in a fixed bitset of `Pool.max_entries`
|
||||
// bits. Without this check a config past that bound reaches an assert and
|
||||
// panics at startup, which is the wrong way to tell an operator that a
|
||||
// number is too large. Disabled upstreams are not counted: they never
|
||||
// become entries.
|
||||
if (enabled_upstreams > pool.Pool.max_entries) {
|
||||
try diags.add(
|
||||
error.TooManyUpstreams,
|
||||
"upstreams",
|
||||
.{},
|
||||
"{d} upstreams are enabled; nxdns is built for at most {d}",
|
||||
.{ enabled_upstreams, pool.Pool.max_entries },
|
||||
);
|
||||
}
|
||||
|
||||
var client_ips: IndexSet = .empty;
|
||||
for (cfg.clients, 0..) |client, i| {
|
||||
@@ -1323,6 +1341,52 @@ test "error.NoUpstreams when nothing is enabled" {
|
||||
try expectProblem(cfg, error.NoUpstreams, "upstreams");
|
||||
}
|
||||
|
||||
/// `count` distinct enabled upstreams. Generated rather than written out
|
||||
/// because the bound this exercises is 64, and a hand-written list that long
|
||||
/// would say less than the loop does.
|
||||
fn ManyUpstreams(comptime count: usize) type {
|
||||
return struct {
|
||||
const list: [count]model.UpstreamServer = blk: {
|
||||
var built: [count]model.UpstreamServer = undefined;
|
||||
for (&built, 0..) |*server, i| {
|
||||
server.* = .{ .url = std.fmt.comptimePrint("https://u{d}.example/dns-query", .{i}) };
|
||||
}
|
||||
break :blk built;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
fn manyUpstreams(comptime count: usize) []const model.UpstreamServer {
|
||||
return &ManyUpstreams(count).list;
|
||||
}
|
||||
|
||||
test "as many enabled upstreams as the pool holds validates cleanly" {
|
||||
var cfg = baseConfig();
|
||||
cfg.upstreams = manyUpstreams(pool.Pool.max_entries);
|
||||
try expectClean(cfg);
|
||||
}
|
||||
|
||||
test "error.TooManyUpstreams one enabled upstream past the pool's bound" {
|
||||
// The pool asserts this bound, so without the check here a valid-looking
|
||||
// config panics at startup instead of being reported.
|
||||
var cfg = baseConfig();
|
||||
cfg.upstreams = manyUpstreams(pool.Pool.max_entries + 1);
|
||||
try expectProblem(cfg, error.TooManyUpstreams, "upstreams");
|
||||
}
|
||||
|
||||
test "upstreams past the pool's bound are fine while they are disabled" {
|
||||
// Only enabled upstreams become pool entries, so a long list with a small
|
||||
// enabled subset is not near the bound at all.
|
||||
var cfg = baseConfig();
|
||||
cfg.upstreams = comptime blk: {
|
||||
var list = manyUpstreams(pool.Pool.max_entries + 1)[0 .. pool.Pool.max_entries + 1].*;
|
||||
for (list[1..]) |*server| server.enabled = false;
|
||||
const frozen = list;
|
||||
break :blk &frozen;
|
||||
};
|
||||
try expectClean(cfg);
|
||||
}
|
||||
|
||||
test "error.BadUpstreamUrl on an unsupported scheme" {
|
||||
var cfg = baseConfig();
|
||||
cfg.upstreams = &.{.{ .url = "ftp://dns.example/" }};
|
||||
|
||||
@@ -73,6 +73,29 @@ const npm_not_shipped = [_][]const u8{
|
||||
"aria-hidden",
|
||||
"client-only",
|
||||
"tslib",
|
||||
"@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",
|
||||
"@types/react-dom",
|
||||
"csstype",
|
||||
"@visx/curve",
|
||||
"@visx/vendor",
|
||||
"d3-delaunay",
|
||||
"d3-geo",
|
||||
"d3-time-format",
|
||||
"delaunator",
|
||||
"robust-predicates",
|
||||
"react-use-measure",
|
||||
};
|
||||
|
||||
/// Components the shipped artifacts contain that no dependency file mentions,
|
||||
@@ -158,6 +181,24 @@ const npm_licence_exceptions = [_]NpmLicence{
|
||||
// and @stylexjs/stylex's compiler.
|
||||
.{ .name = "isbot", .licence = "Unlicense" },
|
||||
.{ .name = "css-mediaquery", .licence = "BSD" },
|
||||
// Shipped: the d3 modules visx computes its geometry with. Mokhtar Mial
|
||||
// accepted ISC inbound on 2026-08-24; licenses/d3-isc.txt carries the text.
|
||||
.{ .name = "d3-array", .licence = "ISC" },
|
||||
.{ .name = "d3-color", .licence = "ISC" },
|
||||
.{ .name = "d3-format", .licence = "ISC" },
|
||||
.{ .name = "d3-interpolate", .licence = "ISC" },
|
||||
.{ .name = "d3-path", .licence = "ISC" },
|
||||
.{ .name = "d3-scale", .licence = "ISC" },
|
||||
.{ .name = "d3-shape", .licence = "ISC" },
|
||||
.{ .name = "d3-time", .licence = "ISC" },
|
||||
.{ .name = "internmap", .licence = "ISC" },
|
||||
// Not shipped: the rest of the d3 closure, reached only through
|
||||
// @visx/vendor's map and Voronoi re-exports, which no chart here imports.
|
||||
.{ .name = "d3-delaunay", .licence = "ISC" },
|
||||
.{ .name = "d3-geo", .licence = "ISC" },
|
||||
.{ .name = "d3-time-format", .licence = "ISC" },
|
||||
.{ .name = "delaunator", .licence = "ISC" },
|
||||
.{ .name = "robust-predicates", .licence = "Unlicense" },
|
||||
};
|
||||
|
||||
const NpmLicence = struct {
|
||||
|
||||
+147
-19
@@ -109,6 +109,11 @@ pub const ForwardClient = struct {
|
||||
|
||||
/// `.udp` resolvers send one datagram and fall back to TCP when the answer
|
||||
/// comes back with TC=1. `.tcp` resolvers skip straight to the TCP path.
|
||||
///
|
||||
/// `read_timeout` bounds the WHOLE exchange, truncation fallback included:
|
||||
/// the instant is computed once here and every blocking step inside runs
|
||||
/// against it, so a truncated UDP answer followed by a stalled TCP retry
|
||||
/// costs one budget rather than two.
|
||||
pub fn exchange(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
@@ -120,10 +125,22 @@ pub const ForwardClient = struct {
|
||||
if (response_buf.len == 0) return error.BufferTooSmall;
|
||||
|
||||
self.stats.queries += 1;
|
||||
return self.route(io, query, response_buf) catch |err| {
|
||||
const expiry_at: std.Io.Clock.Timestamp = .fromNow(io, self.read_timeout);
|
||||
// The outcome is deliberately discarded. A forward zone has exactly one
|
||||
// configured resolver, so an expiry here is still that resolver failing
|
||||
// to answer in time: `error.Timeout` is peer evidence, and the
|
||||
// budget/peer distinction is upstream-pool policy.
|
||||
var outcome: transport.RaceOutcome = .completed;
|
||||
return transport.raceUntilTagged(io, expiry_at, &outcome, route, .{
|
||||
self,
|
||||
io,
|
||||
query,
|
||||
response_buf,
|
||||
expiry_at,
|
||||
}) catch |err| {
|
||||
switch (transport.group(err)) {
|
||||
.peer_fault, .local_resource => self.stats.failures += 1,
|
||||
.cancellation => {},
|
||||
.cancellation, .budget_exhausted => {},
|
||||
}
|
||||
return err;
|
||||
};
|
||||
@@ -134,11 +151,12 @@ pub const ForwardClient = struct {
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
expiry_at: std.Io.Clock.Timestamp,
|
||||
) transport.ExchangeError![]u8 {
|
||||
if (self.resolver.scheme == .udp) {
|
||||
if (try self.exchangeUdp(io, query, response_buf)) |reply| return reply;
|
||||
if (try self.exchangeUdp(io, query, response_buf, expiry_at)) |reply| return reply;
|
||||
}
|
||||
return self.exchangeTcp(io, query, response_buf);
|
||||
return self.tcpOnce(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// `null` means the resolver set TC=1 and the caller must retry over TCP.
|
||||
@@ -151,6 +169,7 @@ pub const ForwardClient = struct {
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
expiry_at: std.Io.Clock.Timestamp,
|
||||
) transport.ExchangeError!?[]u8 {
|
||||
const dest = self.destination();
|
||||
const local = wildcardFor(dest);
|
||||
@@ -166,9 +185,10 @@ pub const ForwardClient = struct {
|
||||
return transport.mapPhase(err, error.SendFailed);
|
||||
};
|
||||
|
||||
// A deadline, not a duration: a discarded foreign datagram restarts the
|
||||
// receive, and a duration would hand each retry the full budget again.
|
||||
const deadline = (std.Io.Timeout{ .duration = self.read_timeout }).toDeadline(io);
|
||||
// The exchange-wide instant, not a fresh duration: a discarded foreign
|
||||
// datagram restarts the receive, and a duration would hand each retry
|
||||
// the full budget again.
|
||||
const deadline: std.Io.Timeout = .{ .deadline = expiry_at };
|
||||
|
||||
while (true) {
|
||||
const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) {
|
||||
@@ -205,18 +225,11 @@ pub const ForwardClient = struct {
|
||||
}
|
||||
}
|
||||
|
||||
/// The read budget bounds the whole TCP exchange through
|
||||
/// `transport.raceWithin`. `ConnectOptions.timeout` is never set: the
|
||||
/// Threaded backend panics on it (Threaded.zig:12076).
|
||||
fn exchangeTcp(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
return transport.raceWithin(io, self.read_timeout, tcpOnce, .{ self, io, query, response_buf });
|
||||
}
|
||||
|
||||
/// Unbounded on its own: `exchange` runs it inside the exchange-wide race,
|
||||
/// which is what cancels a stalled connect or read. It takes no budget of
|
||||
/// its own, so a truncation fallback does not start a second one.
|
||||
/// `ConnectOptions.timeout` is never set: the Threaded backend panics on it
|
||||
/// (Threaded.zig:12076).
|
||||
fn tcpOnce(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
@@ -307,6 +320,11 @@ fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transp
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
const build_options = @import("build_options");
|
||||
const name_mod = @import("../dns/name.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const question = @import("../dns/question.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
|
||||
fn testBuf() [min_frame_buf]u8 {
|
||||
return undefined;
|
||||
@@ -467,3 +485,113 @@ test "a stashed stream error is preferred over the collapsed one" {
|
||||
receiveFailure(&stream_reader, error.EndOfStream),
|
||||
);
|
||||
}
|
||||
|
||||
const one_budget_ms = 400;
|
||||
/// Late enough in the budget that a second, fresh budget for the TCP leg would
|
||||
/// be unmistakable in the elapsed time.
|
||||
const truncate_after_ms = 300;
|
||||
|
||||
/// Answers the first datagram late in the budget with TC=1, which sends the
|
||||
/// client to TCP — where a listener that never accepts leaves it stalled.
|
||||
fn truncatingThenStallingResolver(io: std.Io, socket: *const net.Socket) void {
|
||||
var buf: [2048]u8 = undefined;
|
||||
const msg = socket.receive(io, &buf) catch return;
|
||||
const request = packet.parse(msg.data) catch return;
|
||||
|
||||
(std.Io.Clock.Duration{
|
||||
.raw = .fromMilliseconds(truncate_after_ms),
|
||||
.clock = .awake,
|
||||
}).sleep(io) catch return;
|
||||
|
||||
// The question has to be echoed: `transport.validateResponse` runs before
|
||||
// the client reads the TC bit, so a bare header would come back as
|
||||
// `BadResponse` and never reach the TCP fallback this case is about.
|
||||
const q = packet.firstQuestion(request) orelse return;
|
||||
var reply_buf: [512]u8 = undefined;
|
||||
var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch return;
|
||||
const reply = b.finish();
|
||||
|
||||
var parsed = dns_header.parse(reply) catch return;
|
||||
parsed.flags.tc = true;
|
||||
dns_header.encode(parsed, reply[0..types.header_len]);
|
||||
socket.send(io, &msg.from, reply) catch return;
|
||||
}
|
||||
|
||||
fn testQuery(io: std.Io, buf: []u8) ![]const u8 {
|
||||
var id_bytes: [2]u8 = undefined;
|
||||
io.random(&id_bytes);
|
||||
dns_header.encode(.{
|
||||
.id = std.mem.readInt(u16, &id_bytes, .big),
|
||||
.flags = .{
|
||||
.rcode = .no_error,
|
||||
.z = 0,
|
||||
.ra = false,
|
||||
.rd = true,
|
||||
.tc = false,
|
||||
.aa = false,
|
||||
.opcode = .query,
|
||||
.qr = false,
|
||||
},
|
||||
.qdcount = 1,
|
||||
.ancount = 0,
|
||||
.nscount = 0,
|
||||
.arcount = 0,
|
||||
}, buf[0..types.header_len]);
|
||||
|
||||
var w: std.Io.Writer = .fixed(buf[types.header_len..]);
|
||||
try question.encode(.{
|
||||
.name = try name_mod.fromText("nas.lan"),
|
||||
.qtype = .a,
|
||||
.qclass = .in,
|
||||
}, &w);
|
||||
return buf[0 .. types.header_len + w.buffered().len];
|
||||
}
|
||||
|
||||
test "one budget covers the udp leg, the TC=1 fallback and the tcp leg" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
const bind_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
const socket = try bind_address.bind(io, .{ .mode = .dgram });
|
||||
defer socket.close(io);
|
||||
const port = socket.address.ip4.port;
|
||||
|
||||
// Bound but never accepted: the connect completes out of the kernel's
|
||||
// backlog and the read then waits forever, which is the stall a second
|
||||
// budget would be spent on.
|
||||
const tcp_address: net.IpAddress = try .parse("127.0.0.1", port);
|
||||
var tcp_listener = try tcp_address.listen(io, .{ .reuse_address = true });
|
||||
defer tcp_listener.deinit(io);
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
try group.concurrent(io, truncatingThenStallingResolver, .{ io, &socket });
|
||||
|
||||
var url_buf: [64]u8 = undefined;
|
||||
const url = try std.fmt.bufPrint(&url_buf, "udp://127.0.0.1:{d}", .{port});
|
||||
|
||||
var frame_buf: [min_frame_buf]u8 = undefined;
|
||||
var fc: ForwardClient = .init(
|
||||
try validate.parseResolver(url),
|
||||
&frame_buf,
|
||||
.{ .raw = .fromMilliseconds(one_budget_ms), .clock = .awake },
|
||||
);
|
||||
|
||||
var query_buf: [types.header_len + types.max_name_len + 4]u8 = undefined;
|
||||
const query = try testQuery(io, &query_buf);
|
||||
var response_buf: [2048]u8 = undefined;
|
||||
|
||||
const started = std.Io.Clock.awake.now(io);
|
||||
try testing.expectError(error.Timeout, fc.exchange(io, query, &response_buf));
|
||||
const elapsed = started.durationTo(std.Io.Clock.awake.now(io)).nanoseconds;
|
||||
|
||||
// Two budgets would spend 300 ms on the UDP leg and then a fresh 400 ms on
|
||||
// the stalled TCP leg. The bound sits between one budget and that sum, so
|
||||
// the double-budget shape cannot pass.
|
||||
try testing.expect(elapsed < @as(i96, one_budget_ms + truncate_after_ms / 2) * std.time.ns_per_ms);
|
||||
try testing.expectEqual(@as(u64, 1), fc.stats.udp_truncated);
|
||||
try testing.expectEqual(@as(u64, 1), fc.stats.failures);
|
||||
}
|
||||
|
||||
+11
-2
@@ -676,7 +676,10 @@ const Context = struct {
|
||||
const answer = client.exchange(ctx.io, ctx.query, ctx.response_buf) catch |err| {
|
||||
return switch (transport.group(err)) {
|
||||
.cancellation => .drop,
|
||||
.peer_fault, .local_resource => ctx.servFail(),
|
||||
// A budget that ran out is SERVFAIL like any other failure: no
|
||||
// rcode says "I gave up in time". It is not logged per query —
|
||||
// `nxdns_upstream_budget_exhausted_total` is the record.
|
||||
.peer_fault, .local_resource, .budget_exhausted => ctx.servFail(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -737,7 +740,13 @@ const Context = struct {
|
||||
// the client is about to lose the socket anyway; the listener's
|
||||
// own counters record the abandoned datagram.
|
||||
.cancellation => return .drop,
|
||||
.peer_fault, .local_resource => return ctx.servFail(),
|
||||
// A budget that ran out is SERVFAIL like any other failure: no
|
||||
// rcode says "I gave up in time". It is not logged per query —
|
||||
// `nxdns_upstream_budget_exhausted_total` is the record — and
|
||||
// the pool leaves `selected` unchanged, so the row still names
|
||||
// the last attributable endpoint if there was one, and names
|
||||
// none only when no attributable attempt happened.
|
||||
.peer_fault, .local_resource, .budget_exhausted => return ctx.servFail(),
|
||||
};
|
||||
};
|
||||
bump(&ctx.handler.stats.queries);
|
||||
|
||||
@@ -176,7 +176,7 @@ const EntryStorage = struct {
|
||||
.priority = priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.sem = .{ .permits = self.slots.len },
|
||||
.admission = .{ .permits = self.slots.len },
|
||||
.reuse_recoveries = &self.recoveries,
|
||||
};
|
||||
}
|
||||
|
||||
+22
-10
@@ -30,6 +30,7 @@
|
||||
//! `writer_failed`, so the loss is visible rather than silent.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const db = @import("db.zig");
|
||||
@@ -555,13 +556,17 @@ pub const Logger = struct {
|
||||
/// group it cancels for exactly that reason.
|
||||
///
|
||||
/// `monitor` is the §11.6 gate. Null disables gating.
|
||||
///
|
||||
/// `gpa` belongs to the `BatchWriter` for that writer's whole life; it
|
||||
/// allocates the projection deltas of one batch and nothing else.
|
||||
pub fn runWriter(
|
||||
self: *Logger,
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
database: *db.Db,
|
||||
monitor: ?*disk_monitor.Monitor,
|
||||
) std.Io.Cancelable!void {
|
||||
var writer = queries_repo.BatchWriter.init(database) catch |err| {
|
||||
var writer = queries_repo.BatchWriter.init(gpa, database) catch |err| {
|
||||
scope.warn("query logger: preparing the batch statements failed: {s}", .{@errorName(err)});
|
||||
// Without a writer there is no consumer, so leaving the queue open
|
||||
// would silently swallow every later entry.
|
||||
@@ -1134,7 +1139,7 @@ test "an entry with every provenance field set survives the queue, toRow, insert
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, &database);
|
||||
defer writer.deinit();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
@@ -1466,6 +1471,7 @@ test "shutdown writes the batch the writer holds and the rest of the queue" {
|
||||
var future = try io.concurrent(Logger.runWriter, .{
|
||||
&logger,
|
||||
io,
|
||||
testing.allocator,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
@@ -1502,7 +1508,7 @@ test "entries that arrive inside one window reach the database in one batch" {
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, &database);
|
||||
defer writer.deinit();
|
||||
|
||||
var buf: [16]Entry = undefined;
|
||||
@@ -1559,6 +1565,7 @@ test "the writer holds an entry for the length of the flush interval" {
|
||||
var future = try io.concurrent(Logger.runWriter, .{
|
||||
&logger,
|
||||
io,
|
||||
testing.allocator,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
@@ -1611,6 +1618,7 @@ test "a full batch flushes without waiting for the interval" {
|
||||
var future = try io.concurrent(Logger.runWriter, .{
|
||||
&logger,
|
||||
io,
|
||||
testing.allocator,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
@@ -1658,6 +1666,7 @@ test "the writer's next cycle uses the interval set since its last one" {
|
||||
var future = try io.concurrent(Logger.runWriter, .{
|
||||
&logger,
|
||||
io,
|
||||
testing.allocator,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
@@ -1700,7 +1709,7 @@ test "a gated flush holds the batch until the disk recovers" {
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, &database);
|
||||
defer writer.deinit();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
@@ -1754,7 +1763,7 @@ test "a failing batch is dropped whole and the writer stays usable" {
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, &database);
|
||||
defer writer.deinit();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
@@ -1789,7 +1798,7 @@ test "a writer that cannot prepare closes the queue and counts every entry" {
|
||||
|
||||
for (0..3) |i| logger.log(io, sampleEntry(@intCast(i), "early.example"));
|
||||
|
||||
try logger.runWriter(io, &database, null);
|
||||
try logger.runWriter(io, testing.allocator, &database, null);
|
||||
|
||||
try testing.expect(logger.writer_failed.load(.acquire));
|
||||
try testing.expectEqual(@as(u64, 3), logger.queries_dropped.load(.monotonic));
|
||||
@@ -1842,6 +1851,7 @@ test "the gating episode opens on the gate, turns losing on a drop, and clears o
|
||||
var future = try io.concurrent(Logger.runWriter, .{
|
||||
&logger,
|
||||
io,
|
||||
testing.allocator,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
@@ -2023,6 +2033,7 @@ test "a canceled writer counts the batch it was holding" {
|
||||
var future = try io.concurrent(Logger.runWriter, .{
|
||||
&logger,
|
||||
io,
|
||||
testing.allocator,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
@@ -2072,6 +2083,7 @@ test "a disk-gated writer drops what it holds at shutdown instead of hanging" {
|
||||
var future = try io.concurrent(Logger.runWriter, .{
|
||||
&logger,
|
||||
io,
|
||||
testing.allocator,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
@@ -2121,7 +2133,7 @@ test "an empty batch touches neither the database nor the counters" {
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, &database);
|
||||
defer writer.deinit();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
@@ -2155,7 +2167,7 @@ test "a dropped batch opens an error episode the next good batch closes" {
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, &database);
|
||||
defer writer.deinit();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
@@ -2198,7 +2210,7 @@ test "a writer that cannot prepare leaves an episode no recovery path claims" {
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
logger.diagnostics = &fx.store;
|
||||
|
||||
try logger.runWriter(io, &database, null);
|
||||
try logger.runWriter(io, testing.allocator, &database, null);
|
||||
|
||||
try testing.expectEqualStrings("writer", try fx.text(
|
||||
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
|
||||
@@ -2209,7 +2221,7 @@ test "a writer that cannot prepare leaves an episode no recovery path claims" {
|
||||
|
||||
// The writer returned, so nothing can ever close this. A second run finds
|
||||
// the queue closed and adds no second episode.
|
||||
try logger.runWriter(io, &database, null);
|
||||
try logger.runWriter(io, testing.allocator, &database, null);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
|
||||
@@ -248,6 +248,7 @@ pub const Controller = struct {
|
||||
owned.writer = try io.concurrent(logger.Logger.runWriter, .{
|
||||
generation.logger,
|
||||
io,
|
||||
opts.gpa,
|
||||
&owned.database,
|
||||
opts.monitor,
|
||||
});
|
||||
@@ -434,7 +435,7 @@ pub const Controller = struct {
|
||||
errdefer generation.deinit(self.gpa);
|
||||
const owned = &generation.owned.?;
|
||||
|
||||
owned.writer = try io.concurrent(runParkedWriter, .{ generation, io, self.monitor });
|
||||
owned.writer = try io.concurrent(runParkedWriter, .{ generation, io, self.gpa, self.monitor });
|
||||
|
||||
// The statements are prepared before anything is published, so a
|
||||
// failure here is a refused settings change rather than a writer that
|
||||
@@ -565,11 +566,12 @@ fn drain(generation: *Generation, io: std.Io) void {
|
||||
fn runParkedWriter(
|
||||
generation: *Generation,
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
monitor: ?*disk_monitor.Monitor,
|
||||
) std.Io.Cancelable!void {
|
||||
const owned = &generation.owned.?;
|
||||
|
||||
var writer = queries_repo.BatchWriter.init(&owned.database) catch |err| {
|
||||
var writer = queries_repo.BatchWriter.init(gpa, &owned.database) catch |err| {
|
||||
owned.prepare_error = err;
|
||||
owned.ready.set(io);
|
||||
return;
|
||||
|
||||
@@ -144,7 +144,7 @@ fn awaitCount(counter: *const std.atomic.Value(u64), target: u64, limit: usize)
|
||||
}
|
||||
|
||||
fn writeRows(database: *db.Db, timestamps: []const i64, domain: []const u8) !void {
|
||||
var writer = try queries_repo.BatchWriter.init(database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, database);
|
||||
defer writer.deinit();
|
||||
|
||||
var rows: [16]queries_repo.Row = undefined;
|
||||
@@ -208,6 +208,7 @@ test "S8 case 1: the logger writes a real querylog.db end to end" {
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
testing.allocator,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
@@ -256,6 +257,7 @@ test "S8 case 2: a single entry reaches the file once the flush interval passes"
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
testing.allocator,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
@@ -311,6 +313,7 @@ test "S8 case 3: a full queue drops the oldest entries and the newest survive" {
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
testing.allocator,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
@@ -359,6 +362,7 @@ test "S8 case 4: the privacy transforms reach the stored rows" {
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
testing.allocator,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
@@ -459,6 +463,7 @@ test "S8 case 6: a critical disk gates the flushes and recovery releases them" {
|
||||
var future = try io.concurrent(logger.Logger.runWriter, .{
|
||||
&query_log,
|
||||
io,
|
||||
testing.allocator,
|
||||
log_db.database(),
|
||||
@as(?*disk_monitor.Monitor, &monitor),
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ const log = std.log.scoped(.querylog_schema);
|
||||
/// PLAN §11.3, plus the coverage watermark of milestone 28. Multi-statement
|
||||
/// text — it goes through `db.Db.exec`, never through `prepare`.
|
||||
///
|
||||
/// The trailing INSERT seeds `querylog_meta`, which is part of the schema
|
||||
/// The INSERT seeds `querylog_meta`, which is part of the schema
|
||||
/// rather than a later step: a `query_log` with no watermark beside it cannot
|
||||
/// answer whether an empty result means "no queries" or "no history", and every
|
||||
/// database this program reads from is created by executing this string.
|
||||
@@ -36,6 +36,13 @@ const log = std.log.scoped(.querylog_schema);
|
||||
/// logged in the same second the file was created is not evidence that the
|
||||
/// second is completely covered, and the watermark's whole job is to be
|
||||
/// conservative. From there it only ever advances, in `queries_repo.pruneOlderThan`.
|
||||
///
|
||||
/// The four `bucket_*` tables are the Overview projections (milestone 36), on a
|
||||
/// 30-minute grain that divides every serving width the API offers. They carry
|
||||
/// no history of their own: they are born with the file and maintained in the
|
||||
/// same transaction as every insert and every prune, so SQLite's transaction is
|
||||
/// the only coherence mechanism there is. There is no backfill path — a file
|
||||
/// whose projections could disagree with its rows cannot exist.
|
||||
pub const ddl: [:0]const u8 =
|
||||
\\CREATE TABLE domains (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
@@ -78,6 +85,40 @@ pub const ddl: [:0]const u8 =
|
||||
\\);
|
||||
\\INSERT INTO querylog_meta (id, created_at, available_since)
|
||||
\\VALUES (1, unixepoch(), unixepoch() + 1);
|
||||
\\
|
||||
\\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;
|
||||
;
|
||||
|
||||
/// The fingerprint of an arbitrary DDL text. `tools/cut.zig` calls this at
|
||||
@@ -324,13 +365,23 @@ test "ddl creates the query-log tables and every index" {
|
||||
try database.exec(ddl);
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(i64, 3),
|
||||
@as(i64, 7),
|
||||
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
|
||||
);
|
||||
// The three explicit indexes plus `domains.domain`'s autoindex, and
|
||||
// nothing else: the four projection tables are WITHOUT ROWID, so each
|
||||
// one's PRIMARY KEY *is* its storage rather than a second b-tree to keep
|
||||
// in step on every insert.
|
||||
try testing.expectEqual(
|
||||
@as(i64, 4),
|
||||
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='index'"),
|
||||
);
|
||||
const objects = [_][]const u8{
|
||||
"domains", "query_log",
|
||||
"idx_query_log_ts", "idx_query_log_client",
|
||||
"idx_query_log_domain", "querylog_meta",
|
||||
"bucket_totals", "bucket_clients",
|
||||
"bucket_types", "bucket_routes",
|
||||
};
|
||||
for (objects) |name| {
|
||||
var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -251,7 +251,7 @@ fn openLog() !db.Db {
|
||||
}
|
||||
|
||||
fn writeRows(database: *db.Db, timestamps: []const i64) !void {
|
||||
var writer = try queries_repo.BatchWriter.init(database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, database);
|
||||
defer writer.deinit();
|
||||
var rows: [8]queries_repo.Row = undefined;
|
||||
for (timestamps, rows[0..timestamps.len]) |timestamp, *row| {
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ comptime {
|
||||
_ = @import("web/server_integration_test.zig");
|
||||
_ = @import("server/local_tables.zig");
|
||||
_ = @import("web/metrics.zig");
|
||||
_ = @import("web/handlers/stats.zig");
|
||||
_ = @import("web/handlers/overview.zig");
|
||||
_ = @import("web/handlers/queries.zig");
|
||||
_ = @import("web/handlers/diagnostics.zig");
|
||||
_ = @import("web/handlers/lookup.zig");
|
||||
|
||||
@@ -458,7 +458,7 @@ test "a session the upstream closed is recovered by one redial and counted, not
|
||||
.priority = 10,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.sem = .{ .permits = slots.len },
|
||||
.admission = .{ .permits = slots.len },
|
||||
.reuse_recoveries = &fixture.recoveries,
|
||||
}};
|
||||
var pool: pool_mod.Pool = .init(&entries, .{
|
||||
|
||||
@@ -500,7 +500,7 @@ const Upstreams = struct {
|
||||
.priority = server.priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.sem = .{ .permits = entry_slots.len },
|
||||
.admission = .{ .permits = entry_slots.len },
|
||||
.reuse_recoveries = &self.recovery_counters[self.used],
|
||||
};
|
||||
self.used += 1;
|
||||
|
||||
+814
-139
File diff suppressed because it is too large
Load Diff
+152
-30
@@ -1,4 +1,4 @@
|
||||
//! Shared vocabulary for every upstream client: endpoint URLs, the three
|
||||
//! Shared vocabulary for every upstream client: endpoint URLs, the four
|
||||
//! disjoint failure groups, the `Client` interface, and response validation.
|
||||
//!
|
||||
//! Everything here except the `Client` vtable is pure. `validateResponse` takes
|
||||
@@ -8,8 +8,10 @@
|
||||
//!
|
||||
//! The failure classification is the reason this file exists. Health and
|
||||
//! backoff must count only what the peer did wrong: a local `OutOfMemory` says
|
||||
//! nothing about the upstream, and `error.Canceled` says nothing at all. The
|
||||
//! three error sets below are disjoint by construction and `group` switches
|
||||
//! nothing about the upstream, `error.Canceled` says nothing at all, and
|
||||
//! `error.BudgetExhausted` says the caller ran out of time before the peer was
|
||||
//! given its interval. The four error sets below are disjoint by construction
|
||||
//! and `group` switches
|
||||
//! over them exhaustively, so a new failure mode cannot silently land in the
|
||||
//! wrong bucket.
|
||||
|
||||
@@ -188,9 +190,15 @@ pub const LocalResource = error{
|
||||
|
||||
pub const Cancellation = error{Canceled};
|
||||
|
||||
pub const ExchangeError = PeerFault || LocalResource || Cancellation;
|
||||
/// The caller's own time ran out before any peer could be given the observation
|
||||
/// interval it was configured to get. Evidence about this process's budget, not
|
||||
/// about any endpoint, so it is never recorded against health — that is the
|
||||
/// whole reason it is not a `PeerFault`.
|
||||
pub const BudgetFault = error{BudgetExhausted};
|
||||
|
||||
pub const Group = enum { peer_fault, local_resource, cancellation };
|
||||
pub const ExchangeError = PeerFault || LocalResource || Cancellation || BudgetFault;
|
||||
|
||||
pub const Group = enum { peer_fault, local_resource, cancellation, budget_exhausted };
|
||||
|
||||
/// Exhaustive switch over `ExchangeError` — no `else` arm. A new error member
|
||||
/// must break the build here, so no failure can silently land in the wrong
|
||||
@@ -218,6 +226,8 @@ pub fn group(err: ExchangeError) Group {
|
||||
=> .local_resource,
|
||||
|
||||
error.Canceled => .cancellation,
|
||||
|
||||
error.BudgetExhausted => .budget_exhausted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,29 +279,29 @@ pub fn closeBlocked(io: std.Io, target: anytype) void {
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload of `f`'s return type, which `raceWithin` requires to be
|
||||
/// The payload of `f`'s return type, which the race harness requires to be
|
||||
/// `ExchangeError!T`. A raced function with any other error set would let a
|
||||
/// failure reach the pool without passing through `group`.
|
||||
fn RacedPayload(comptime f: anytype) type {
|
||||
const info = @typeInfo(@TypeOf(f));
|
||||
if (info != .@"fn") @compileError("raceWithin needs a function, found " ++ @typeName(@TypeOf(f)));
|
||||
if (info != .@"fn") @compileError("the race harness needs a function, found " ++ @typeName(@TypeOf(f)));
|
||||
const Return = info.@"fn".return_type orelse
|
||||
@compileError("raceWithin needs a function with a concrete return type");
|
||||
@compileError("the race harness needs a function with a concrete return type");
|
||||
const union_info = switch (@typeInfo(Return)) {
|
||||
.error_union => |u| u,
|
||||
else => @compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return)),
|
||||
else => @compileError("the race harness needs `ExchangeError!T`, found " ++ @typeName(Return)),
|
||||
};
|
||||
if (union_info.error_set != ExchangeError)
|
||||
@compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return));
|
||||
@compileError("the race harness needs `ExchangeError!T`, found " ++ @typeName(Return));
|
||||
return union_info.payload;
|
||||
}
|
||||
|
||||
/// Runs `f(args...)` raced against `budget`, and cancels the loser.
|
||||
///
|
||||
/// No stream read or write in 0.16.0 takes a timeout, so a deadline is a second
|
||||
/// task rather than a socket option. This is the one copy of that harness: the
|
||||
/// pool races an attempt and its whole failover loop through it, and the
|
||||
/// forward client races its TCP exchange.
|
||||
/// task rather than a socket option. `raceUntilTagged` is the one copy of that
|
||||
/// harness; this is the untagged wrapper for callers that own no deadline and
|
||||
/// only need "a bound on this one operation".
|
||||
///
|
||||
/// `error.Timeout` means the budget won. A canceled sleep means the whole task
|
||||
/// is being torn down rather than the budget running out, so it stays
|
||||
@@ -303,33 +313,69 @@ pub fn raceWithin(
|
||||
comptime f: anytype,
|
||||
args: anytype,
|
||||
) ExchangeError!RacedPayload(f) {
|
||||
const Outcome = union(enum) {
|
||||
var outcome: RaceOutcome = .completed;
|
||||
return raceUntilTagged(io, .fromNow(io, budget), &outcome, f, args);
|
||||
}
|
||||
|
||||
/// Which side of the race ended the call.
|
||||
///
|
||||
/// `completed` means the raced operation itself returned — including when what
|
||||
/// it returned is `error.Timeout`, which is then the peer's own timeout and
|
||||
/// real evidence about that peer. `expired` means the caller's clock ran out
|
||||
/// with the operation still in flight, which is evidence about the budget only.
|
||||
pub const RaceOutcome = enum { completed, expired };
|
||||
|
||||
/// `raceWithin` with the two timer origins told apart, and with an ABSOLUTE
|
||||
/// expiry rather than a duration.
|
||||
///
|
||||
/// The timestamp is the point of the whole function. A duration recomputed from
|
||||
/// a deadline and then slept re-anchors at "now", so every re-race drifts a
|
||||
/// little past the caller's real deadline and a truncated attempt is then
|
||||
/// indistinguishable from a full one. The caller that owns the deadline
|
||||
/// computes the instant once and passes it here.
|
||||
///
|
||||
/// `outcome` is written before this returns on both racing paths. It is left
|
||||
/// untouched when the race cannot start at all (`error.SystemResources`) or
|
||||
/// when the whole task is being canceled, since neither is an observation about
|
||||
/// this budget; callers initialize it to the value they want in those cases.
|
||||
pub fn raceUntilTagged(
|
||||
io: std.Io,
|
||||
expiry_at: std.Io.Clock.Timestamp,
|
||||
outcome: *RaceOutcome,
|
||||
comptime f: anytype,
|
||||
args: anytype,
|
||||
) ExchangeError!RacedPayload(f) {
|
||||
const Slot = union(enum) {
|
||||
raced: ExchangeError!RacedPayload(f),
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
var outcomes: [2]Outcome = undefined;
|
||||
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
|
||||
var slots: [2]Slot = undefined;
|
||||
var race: std.Io.Select(Slot) = .init(io, &slots);
|
||||
defer race.cancelDiscard();
|
||||
|
||||
race.concurrent(.raced, f, args) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
};
|
||||
race.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
|
||||
race.concurrent(.expiry, expire, .{ io, expiry_at }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
};
|
||||
|
||||
switch (try race.await()) {
|
||||
.raced => |result| return result,
|
||||
.raced => |result| {
|
||||
outcome.* = .completed;
|
||||
return result;
|
||||
},
|
||||
.expiry => |result| {
|
||||
try result;
|
||||
outcome.* = .expired;
|
||||
return error.Timeout;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
return budget.sleep(io);
|
||||
fn expire(io: std.Io, expiry_at: std.Io.Clock.Timestamp) std.Io.Cancelable!void {
|
||||
return expiry_at.wait(io);
|
||||
}
|
||||
|
||||
/// A thing that sends one DNS message and returns one validated DNS message.
|
||||
@@ -347,13 +393,21 @@ pub const Client = struct {
|
||||
/// Returns a prefix of `response_buf`. The returned message has already
|
||||
/// passed `validateResponse` against `query`.
|
||||
///
|
||||
/// `selected` names the resolver the exchange used. An implementation
|
||||
/// writes it *before* each attempt, never after, so a failed exchange still
|
||||
/// names the last resolver it tried — a SERVFAIL row without its resolver
|
||||
/// explains nothing. The slice must outlive the call; every implementation
|
||||
/// borrows storage it owns for at least the query's duration. Callers
|
||||
/// initialize it to null: a `null` after the call means no resolver was
|
||||
/// reached at all.
|
||||
/// `selected` names the resolver the exchange used. A single-endpoint
|
||||
/// implementation (DoH, DoT, the forward client, test fakes) may write it
|
||||
/// *before* each attempt: it has one resolver and records no health, so
|
||||
/// "the one I tried" is an honest answer even for a failure, and a SERVFAIL
|
||||
/// row without its resolver explains nothing.
|
||||
///
|
||||
/// `Pool` is stricter, and documents the rule on `Pool.exchange`: it names
|
||||
/// only endpoints whose outcome it recorded, so a query that ran out of
|
||||
/// budget blames nobody and may leave this `null`. Both are within this
|
||||
/// contract — the guarantee here is that a non-null value names a resolver
|
||||
/// this exchange really used, never that a failure leaves one behind.
|
||||
///
|
||||
/// The slice must outlive the call; every implementation borrows storage it
|
||||
/// owns for at least the query's duration. Callers initialize it to null: a
|
||||
/// `null` after the call means no resolver is being reported.
|
||||
pub fn exchange(
|
||||
self: Client,
|
||||
io: std.Io,
|
||||
@@ -530,8 +584,8 @@ test "parse rejects a fragment" {
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("tls://dns.google#f"));
|
||||
}
|
||||
|
||||
test "the three error groups are disjoint" {
|
||||
const sets = .{ PeerFault, LocalResource, Cancellation };
|
||||
test "the four error groups are disjoint" {
|
||||
const sets = .{ PeerFault, LocalResource, Cancellation, BudgetFault };
|
||||
inline for (sets, 0..) |a, i| {
|
||||
inline for (sets, 0..) |b, j| {
|
||||
if (i >= j) continue;
|
||||
@@ -548,7 +602,8 @@ test "the three error groups are disjoint" {
|
||||
// member added to two sets at once cannot pass unnoticed.
|
||||
const total = @typeInfo(PeerFault).error_set.?.len +
|
||||
@typeInfo(LocalResource).error_set.?.len +
|
||||
@typeInfo(Cancellation).error_set.?.len;
|
||||
@typeInfo(Cancellation).error_set.?.len +
|
||||
@typeInfo(BudgetFault).error_set.?.len;
|
||||
try testing.expectEqual(total, @typeInfo(ExchangeError).error_set.?.len);
|
||||
}
|
||||
|
||||
@@ -558,6 +613,7 @@ test "group classifies each member" {
|
||||
try testing.expectEqual(Group.local_resource, group(error.OutOfMemory));
|
||||
try testing.expectEqual(Group.local_resource, group(error.BufferTooSmall));
|
||||
try testing.expectEqual(Group.cancellation, group(error.Canceled));
|
||||
try testing.expectEqual(Group.budget_exhausted, group(error.BudgetExhausted));
|
||||
}
|
||||
|
||||
test "mapLocal folds only local and cancellation errors" {
|
||||
@@ -641,6 +697,72 @@ test "raceWithin passes the raced task's own failure through" {
|
||||
);
|
||||
}
|
||||
|
||||
test "raceUntilTagged tells a leaf Timeout apart from an expiry" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// The leaf's own timeout: it returned, so the peer really did time out and
|
||||
// the outcome is `completed` even though the error is the same one an
|
||||
// expiry produces.
|
||||
var outcome: RaceOutcome = .expired;
|
||||
const far: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromSeconds(30), .clock = .awake });
|
||||
try testing.expectError(
|
||||
error.Timeout,
|
||||
raceUntilTagged(io, far, &outcome, racedReply, .{
|
||||
io, 0, @as(ExchangeError!usize, error.Timeout),
|
||||
}),
|
||||
);
|
||||
try testing.expectEqual(RaceOutcome.completed, outcome);
|
||||
|
||||
// The expiry side, distinguishable only through the tag.
|
||||
outcome = .completed;
|
||||
const soon: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromMilliseconds(20), .clock = .awake });
|
||||
try testing.expectError(
|
||||
error.Timeout,
|
||||
raceUntilTagged(io, soon, &outcome, racedReply, .{
|
||||
io, 30_000, @as(ExchangeError!usize, 7),
|
||||
}),
|
||||
);
|
||||
try testing.expectEqual(RaceOutcome.expired, outcome);
|
||||
}
|
||||
|
||||
test "raceUntilTagged tags a successful completion" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var outcome: RaceOutcome = .expired;
|
||||
const far: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromSeconds(30), .clock = .awake });
|
||||
const len = try raceUntilTagged(io, far, &outcome, racedReply, .{
|
||||
io, 0, @as(ExchangeError!usize, 7),
|
||||
});
|
||||
try testing.expectEqual(@as(usize, 7), len);
|
||||
try testing.expectEqual(RaceOutcome.completed, outcome);
|
||||
}
|
||||
|
||||
test "raceUntilTagged honours an expiry already in the past" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// A deadline the caller has already spent: the expiry wins immediately
|
||||
// rather than re-anchoring a duration at "now" and granting a fresh budget.
|
||||
const past = std.Io.Clock.Timestamp.now(io, .awake)
|
||||
.subDuration(.{ .raw = .fromSeconds(1), .clock = .awake });
|
||||
var outcome: RaceOutcome = .completed;
|
||||
const started = std.Io.Clock.awake.now(io);
|
||||
try testing.expectError(
|
||||
error.Timeout,
|
||||
raceUntilTagged(io, past, &outcome, racedReply, .{
|
||||
io, 30_000, @as(ExchangeError!usize, 7),
|
||||
}),
|
||||
);
|
||||
try testing.expectEqual(RaceOutcome.expired, outcome);
|
||||
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
|
||||
try testing.expect(elapsed_ns < @as(i96, 5) * std.time.ns_per_s);
|
||||
}
|
||||
|
||||
test "closeBlocked closes a target of either close shape" {
|
||||
// The two shapes the transports use: a socket or a plain stream, which
|
||||
// closes through the `Io`, and a `TlsStream`, which owns the one it was
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
//! complete for. Without that fact on the wire a chart draws a pruned week as a
|
||||
//! week of silence, which is the one reading that is certainly wrong.
|
||||
//!
|
||||
//! Three endpoints carry it — `/api/queries`, `/api/stats` and
|
||||
//! `/api/stats/timeseries` — and they judge it against their own effective
|
||||
//! lower bound: the client's `since` for the query log, the period's aligned
|
||||
//! window start for the two stats endpoints.
|
||||
//! Two endpoints carry it — `/api/queries` and `/api/overview` — and they judge
|
||||
//! it against their own effective lower bound: the client's `since` for the
|
||||
//! query log, the period's aligned window start for the overview.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
//! `GET /api/overview`: everything the Overview page draws, for one period, in
|
||||
//! one response.
|
||||
//!
|
||||
//! It replaces the five per-panel endpoints milestone 30 shipped. Those cost
|
||||
//! five scans of every raw row in the window and, being five requests, could
|
||||
//! only promise a shared *window* — queries logged between two of them moved
|
||||
//! one panel and not the other. One request over one read transaction promises
|
||||
//! a shared *snapshot*: the totals, the four breakdowns and the coverage
|
||||
//! watermark beside them all describe one database state, so the breakdowns sum
|
||||
//! to the totals for a reason and not by luck.
|
||||
//!
|
||||
//! Buckets are aligned to the UTC grid, not to the moment of the request. Every
|
||||
//! width divides a day, so flooring the current time to a multiple of the width
|
||||
//! puts each bucket on the same boundary a human reads off a clock, and two
|
||||
//! requests a second apart return the same bucket starts. The last bucket is
|
||||
//! the one in progress; it fills as the period runs.
|
||||
//!
|
||||
//! The aggregate runs on the web task's own query-log connection (m7 ruling
|
||||
//! 21), which every connection task shares. SQLite's serialized mode makes one
|
||||
//! call safe; it does not make a transaction safe, so `WebState.querylog_lock`
|
||||
//! covers the whole read and a second BEGIN can never land inside the first.
|
||||
//! The transaction is deferred, not `db.Tx`'s BEGIN IMMEDIATE, which would
|
||||
//! stall the logger and retention behind an HTTP response.
|
||||
//!
|
||||
//! The lock is released before the response is written: the body is already
|
||||
//! built in the request arena, and holding a database lock across a socket
|
||||
//! write would let one slow client serialize every other reader.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const coverage = @import("../coverage.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const log = std.log.scoped(.web_overview);
|
||||
|
||||
/// The four periods ruling 13 defines. The tag names are the wire spellings.
|
||||
pub const Period = enum {
|
||||
@"1h",
|
||||
@"24h",
|
||||
@"7d",
|
||||
@"30d",
|
||||
|
||||
pub fn parse(text: []const u8) ?Period {
|
||||
return std.meta.stringToEnum(Period, text);
|
||||
}
|
||||
|
||||
/// Ruling 13: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h.
|
||||
pub fn bucketSeconds(self: Period) u32 {
|
||||
return switch (self) {
|
||||
.@"1h" => 60,
|
||||
.@"24h" => 30 * 60,
|
||||
.@"7d" => 60 * 60,
|
||||
.@"30d" => 6 * 60 * 60,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn bucketCount(self: Period) u32 {
|
||||
return switch (self) {
|
||||
.@"1h" => 60,
|
||||
.@"24h" => 48,
|
||||
.@"7d" => 168,
|
||||
.@"30d" => 120,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn label(self: Period) []const u8 {
|
||||
return @tagName(self);
|
||||
}
|
||||
};
|
||||
|
||||
pub const default_period: Period = .@"24h";
|
||||
|
||||
/// The widest period's bucket count. Nothing here allocates by it any more —
|
||||
/// the repository returns arena slices — but it is the bound the response size
|
||||
/// argument rests on, and the assertion below is what keeps it true.
|
||||
pub const max_buckets = 168;
|
||||
|
||||
comptime {
|
||||
std.debug.assert(std.enums.values(Period).len == server.OverviewCache.slot_count);
|
||||
for (std.enums.values(Period)) |period| {
|
||||
std.debug.assert(period.bucketCount() <= max_buckets);
|
||||
// The UTC alignment argument holds only while every width divides a day.
|
||||
std.debug.assert(86_400 % period.bucketSeconds() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub const Window = struct {
|
||||
/// Inclusive, on the bucket grid.
|
||||
since: i64,
|
||||
/// Exclusive: the end of the bucket that `now` falls in.
|
||||
until: i64,
|
||||
bucket_seconds: u32,
|
||||
bucket_count: u32,
|
||||
};
|
||||
|
||||
pub fn window(period: Period, now_unix: i64) Window {
|
||||
const width: i64 = period.bucketSeconds();
|
||||
const count: i64 = period.bucketCount();
|
||||
const until = @divFloor(now_unix, width) * width + width;
|
||||
return .{
|
||||
.since = until - width * count,
|
||||
.until = until,
|
||||
.bucket_seconds = period.bucketSeconds(),
|
||||
.bucket_count = period.bucketCount(),
|
||||
};
|
||||
}
|
||||
|
||||
pub const Totals = struct {
|
||||
queries: u64,
|
||||
blocked: u64,
|
||||
/// Distinct client addresses in the window.
|
||||
clients: u64,
|
||||
avg_response_time_us: ?i64,
|
||||
};
|
||||
|
||||
pub const Body = struct {
|
||||
period: []const u8,
|
||||
since: i64,
|
||||
until: i64,
|
||||
bucket_seconds: u32,
|
||||
totals: Totals,
|
||||
buckets: []const queries_repo.Bucket,
|
||||
clients: []const queries_repo.ClientSeries,
|
||||
other: []const u64,
|
||||
types: []const queries_repo.TypeCount,
|
||||
routes: []const queries_repo.RouteCount,
|
||||
/// Judged against `since`, which is the window this body reports on — so a
|
||||
/// dashboard can say "history starts here" instead of charting a pruned
|
||||
/// stretch as a quiet one.
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
|
||||
pub fn handle(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const period = periodParam(request.query) catch return badPeriod(request);
|
||||
const database = state.querylog_db orelse return unavailable(request);
|
||||
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
|
||||
|
||||
const body = cachedBody(state, io, database, request.arena, period, span) catch |err| {
|
||||
return internal(request, err);
|
||||
};
|
||||
return http_util.respondBytes(request, .ok, body, http_util.content_type_json, &.{});
|
||||
}
|
||||
|
||||
/// The whole cache decision, start to finish, under one hold of
|
||||
/// `querylog_lock`. Returns bytes owned by `arena`, so the caller writes the
|
||||
/// socket with the lock already released.
|
||||
///
|
||||
/// `data_version` is sampled inside the lock and the rebuild is published under
|
||||
/// that same sample: a commit landing on another connection while this task
|
||||
/// builds moves the pragma, so the entry it installs is keyed to a version the
|
||||
/// next request will not ask for and that request rebuilds. Stale bytes under a
|
||||
/// current key are therefore not reachable. A failed build or a failed commit
|
||||
/// publishes nothing and leaves whatever the slot already held.
|
||||
fn cachedBody(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
arena: std.mem.Allocator,
|
||||
period: Period,
|
||||
span: Window,
|
||||
) db.Error![]const u8 {
|
||||
state.querylog_lock.lockUncancelable(io);
|
||||
defer state.querylog_lock.unlock(io);
|
||||
|
||||
const index = @intFromEnum(period);
|
||||
const data_version = try database.queryInt("PRAGMA data_version");
|
||||
|
||||
if (state.overview_cache.get(index, span.until, data_version)) |cached| {
|
||||
// The copy is what makes a later rebuild's free-and-replace safe: the
|
||||
// response is written after the lock is gone, and by then these bytes
|
||||
// may belong to nobody.
|
||||
return arena.dupe(u8, cached);
|
||||
}
|
||||
|
||||
const body = try buildBody(state, io, database, arena, period, span);
|
||||
const owned = try state.gpa.dupe(u8, body);
|
||||
state.overview_cache.put(state.gpa, index, span.until, data_version, owned);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// One read transaction, one snapshot, one serialized body in `arena`. The
|
||||
/// caller holds `querylog_lock` and keeps holding it.
|
||||
fn buildBody(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
arena: std.mem.Allocator,
|
||||
period: Period,
|
||||
span: Window,
|
||||
) db.Error![]const u8 {
|
||||
var scope = try server.QuerylogRead.openLocked(state, io, database);
|
||||
errdefer scope.abort();
|
||||
const data = try queries_repo.overview(
|
||||
database,
|
||||
arena,
|
||||
span.since,
|
||||
span.bucket_seconds,
|
||||
span.bucket_count,
|
||||
);
|
||||
const window_coverage = try coverage.read(database, span.since);
|
||||
try scope.commit();
|
||||
|
||||
var allocating: std.Io.Writer.Allocating = .init(arena);
|
||||
errdefer allocating.deinit();
|
||||
std.json.Stringify.value(Body{
|
||||
.period = period.label(),
|
||||
.since = span.since,
|
||||
.until = span.until,
|
||||
.bucket_seconds = span.bucket_seconds,
|
||||
.totals = .{
|
||||
.queries = data.totals.queries,
|
||||
.blocked = data.totals.blocked,
|
||||
.clients = data.totals.distinct_clients,
|
||||
.avg_response_time_us = data.totals.avg_response_time_us,
|
||||
},
|
||||
.buckets = data.buckets,
|
||||
.clients = data.clients.clients,
|
||||
.other = data.clients.other,
|
||||
.types = data.types,
|
||||
.routes = data.routes,
|
||||
.coverage = window_coverage,
|
||||
}, .{}, &allocating.writer) catch return error.OutOfMemory;
|
||||
return allocating.written();
|
||||
}
|
||||
|
||||
pub const PeriodError = error{BadPeriod};
|
||||
|
||||
/// An absent `period` is the default; anything else it cannot read is a 400,
|
||||
/// never a silent fallback — a typo must not return a window nobody asked for.
|
||||
fn periodParam(query: []const u8) PeriodError!Period {
|
||||
var buf: [8]u8 = undefined;
|
||||
const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod;
|
||||
const text = found orelse return default_period;
|
||||
return Period.parse(text) orelse error.BadPeriod;
|
||||
}
|
||||
|
||||
fn badPeriod(request: *http_util.Request) http_util.HandlerError!void {
|
||||
return http_util.respondError(request, .bad_request, "period must be one of 1h, 24h, 7d, 30d");
|
||||
}
|
||||
|
||||
fn unavailable(request: *http_util.Request) http_util.HandlerError!void {
|
||||
return http_util.respondError(request, .service_unavailable, "query log unavailable");
|
||||
}
|
||||
|
||||
/// The one thing this file logs. A failed aggregate is a fault in the box, not
|
||||
/// a property of the request, and the client is told nothing beyond "internal
|
||||
/// error" (ruling 8, PLAN §19).
|
||||
fn internal(request: *http_util.Request, err: db.Error) http_util.HandlerError!void {
|
||||
log.warn("overview failed: {s}", .{@errorName(err)});
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const querylog_schema = @import("../../storage/querylog_schema.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
test "the period grammar accepts exactly the four spellings" {
|
||||
try testing.expectEqual(Period.@"1h", Period.parse("1h").?);
|
||||
try testing.expectEqual(Period.@"24h", Period.parse("24h").?);
|
||||
try testing.expectEqual(Period.@"7d", Period.parse("7d").?);
|
||||
try testing.expectEqual(Period.@"30d", Period.parse("30d").?);
|
||||
try testing.expectEqual(@as(?Period, null), Period.parse("12h"));
|
||||
try testing.expectEqual(@as(?Period, null), Period.parse("1H"));
|
||||
try testing.expectEqual(@as(?Period, null), Period.parse(""));
|
||||
}
|
||||
|
||||
test "an absent period defaults and a bad one is rejected" {
|
||||
try testing.expectEqual(default_period, try periodParam(""));
|
||||
try testing.expectEqual(default_period, try periodParam("limit=5"));
|
||||
try testing.expectEqual(Period.@"7d", try periodParam("period=7d"));
|
||||
try testing.expectError(error.BadPeriod, periodParam("period=12h"));
|
||||
try testing.expectError(error.BadPeriod, periodParam("period=%2"));
|
||||
// Longer than any spelling: rejected rather than truncated to "1h".
|
||||
try testing.expectError(error.BadPeriod, periodParam("period=1hhhhhhhhhh"));
|
||||
}
|
||||
|
||||
test "each period spans its own bucket width times its count" {
|
||||
for (std.enums.values(Period)) |period| {
|
||||
const span = window(period, 1_700_000_000);
|
||||
const width: i64 = period.bucketSeconds();
|
||||
try testing.expectEqual(width * @as(i64, period.bucketCount()), span.until - span.since);
|
||||
}
|
||||
}
|
||||
|
||||
test "the window sits on the UTC grid and ends with the bucket in progress" {
|
||||
// 2023-11-14T22:13:20Z, which is not on any bucket boundary.
|
||||
const now: i64 = 1_700_000_000;
|
||||
const span = window(.@"24h", now);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), @rem(span.since, 1800));
|
||||
try testing.expectEqual(@as(i64, 0), @rem(span.until, 1800));
|
||||
try testing.expect(span.until > now);
|
||||
try testing.expect(span.until - now <= 1800);
|
||||
try testing.expectEqual(@as(u32, 48), span.bucket_count);
|
||||
}
|
||||
|
||||
test "two requests inside one bucket see the same window" {
|
||||
// A bucket boundary, so the offsets below stay inside one minute.
|
||||
const boundary: i64 = 1_700_000_000 - @rem(1_700_000_000, 60);
|
||||
const first = window(.@"1h", boundary);
|
||||
const second = window(.@"1h", boundary + 59);
|
||||
try testing.expectEqual(first.since, second.since);
|
||||
try testing.expectEqual(first.until, second.until);
|
||||
|
||||
const next = window(.@"1h", boundary + 60);
|
||||
try testing.expectEqual(first.until + 60, next.until);
|
||||
}
|
||||
|
||||
test "a timestamp exactly on a boundary starts a new bucket" {
|
||||
const span = window(.@"7d", 1_700_000_000 - 1_700_000_000 % 3600);
|
||||
try testing.expectEqual(@as(i64, 0), @rem(span.since, 3600));
|
||||
try testing.expectEqual(@as(u32, 168), span.bucket_count);
|
||||
}
|
||||
|
||||
test "every period's window is a window the repository will serve" {
|
||||
// The projection path needs the window start on the 30-minute grid and the
|
||||
// width a whole number of grains; `window` is the only producer of either.
|
||||
for (std.enums.values(Period)) |period| {
|
||||
const span = window(period, 1_700_000_123);
|
||||
if (span.bucket_seconds < 1800) continue;
|
||||
try testing.expectEqual(@as(i64, 0), @mod(span.since, 1800));
|
||||
try testing.expectEqual(@as(u32, 0), span.bucket_seconds % 1800);
|
||||
}
|
||||
}
|
||||
|
||||
fn openLog() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
try database.exec(querylog_schema.ddl);
|
||||
return database;
|
||||
}
|
||||
|
||||
fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, cached: ?bool) !void {
|
||||
const rows = [_]queries_repo.Row{.{
|
||||
.timestamp = timestamp,
|
||||
.domain = "example.com",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = blocked,
|
||||
.response_time_us = 1000,
|
||||
.cache_hit = cached,
|
||||
.upstream = null,
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = if (blocked) .block else .allow,
|
||||
.policy_reason = if (blocked) .blocklist_domain else .no_match,
|
||||
.matched = null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = if (blocked) .blocked else .upstream,
|
||||
.forward_zone = null,
|
||||
}};
|
||||
try writer.writeBatch(&rows);
|
||||
}
|
||||
|
||||
test "one overview answers totals and buckets that agree over the same window" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const span = window(.@"1h", 1_700_000_000);
|
||||
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, &database);
|
||||
defer writer.deinit();
|
||||
// One row in the first bucket, two in the last, one just outside.
|
||||
try writeRow(&writer, span.since, false, false);
|
||||
try writeRow(&writer, span.until - 1, true, false);
|
||||
try writeRow(&writer, span.until - 2, false, true);
|
||||
try writeRow(&writer, span.since - 1, false, false);
|
||||
|
||||
const data = try queries_repo.overview(
|
||||
&database,
|
||||
arena_state.allocator(),
|
||||
span.since,
|
||||
span.bucket_seconds,
|
||||
span.bucket_count,
|
||||
);
|
||||
|
||||
try testing.expectEqual(@as(u64, 3), data.totals.queries);
|
||||
try testing.expectEqual(@as(u64, 1), data.totals.blocked);
|
||||
try testing.expectEqual(@as(u64, 1), data.totals.distinct_clients);
|
||||
try testing.expectEqual(@as(?i64, 1000), data.totals.avg_response_time_us);
|
||||
|
||||
try testing.expectEqual(@as(usize, 60), data.buckets.len);
|
||||
var summed: u64 = 0;
|
||||
var blocked: u64 = 0;
|
||||
for (data.buckets) |bucket| {
|
||||
summed += bucket.queries;
|
||||
blocked += bucket.blocked;
|
||||
}
|
||||
try testing.expectEqual(data.totals.queries, summed);
|
||||
try testing.expectEqual(data.totals.blocked, blocked);
|
||||
|
||||
try testing.expectEqual(span.since, data.buckets[0].ts);
|
||||
try testing.expectEqual(@as(u64, 1), data.buckets[0].queries);
|
||||
try testing.expectEqual(@as(u64, 2), data.buckets[59].queries);
|
||||
try testing.expectEqual(span.until - span.bucket_seconds, data.buckets[59].ts);
|
||||
}
|
||||
|
||||
test "an empty window reports zeros with a null mean" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const span = window(.@"30d", 1_700_000_000);
|
||||
const data = try queries_repo.overview(
|
||||
&database,
|
||||
arena_state.allocator(),
|
||||
span.since,
|
||||
span.bucket_seconds,
|
||||
span.bucket_count,
|
||||
);
|
||||
|
||||
try testing.expectEqual(@as(u64, 0), data.totals.queries);
|
||||
try testing.expectEqual(@as(?i64, null), data.totals.avg_response_time_us);
|
||||
try testing.expectEqual(@as(usize, 120), data.buckets.len);
|
||||
for (data.buckets) |bucket| try testing.expectEqual(@as(u64, 0), bucket.queries);
|
||||
// `other` is bucket-count sized even here: a chart must never have to
|
||||
// invent the residual series.
|
||||
try testing.expectEqual(@as(usize, 120), data.clients.other.len);
|
||||
}
|
||||
|
||||
test "the cache serves one period's bytes and rebuilds when the key moves" {
|
||||
const gpa = testing.allocator;
|
||||
var cache: server.OverviewCache = .{};
|
||||
defer cache.deinit(gpa);
|
||||
|
||||
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7));
|
||||
|
||||
cache.put(gpa, 0, 100, 7, try gpa.dupe(u8, "first"));
|
||||
try testing.expectEqualStrings("first", cache.get(0, 100, 7).?);
|
||||
// A different period, a rolled window and a bumped data version are three
|
||||
// different keys, and none of them hits.
|
||||
try testing.expectEqual(@as(?[]const u8, null), cache.get(1, 100, 7));
|
||||
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 101, 7));
|
||||
try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 8));
|
||||
|
||||
// A rebuild replaces the entry and frees the old body; the leak checker in
|
||||
// `testing.allocator` is the assertion.
|
||||
cache.put(gpa, 0, 100, 8, try gpa.dupe(u8, "second"));
|
||||
try testing.expectEqualStrings("second", cache.get(0, 100, 8).?);
|
||||
}
|
||||
|
||||
/// Two connections onto one file, which is the only arrangement in which
|
||||
/// `PRAGMA data_version` moves at all: it reports commits by *other*
|
||||
/// connections, so an in-memory database — where there is no other connection —
|
||||
/// could never witness the invalidation these tests are about.
|
||||
const CacheFixture = struct {
|
||||
threaded: std.Io.Threaded,
|
||||
tmp: std.testing.TmpDir,
|
||||
/// The web task's connection, the one the cache is keyed on.
|
||||
reader: db.Db,
|
||||
/// Stands in for the logger and for retention.
|
||||
writer: db.Db,
|
||||
state: server.WebState,
|
||||
arena_state: std.heap.ArenaAllocator,
|
||||
|
||||
fn init(self: *CacheFixture, gpa: std.mem.Allocator) !void {
|
||||
self.threaded = .init(gpa, .{});
|
||||
errdefer self.threaded.deinit();
|
||||
self.tmp = std.testing.tmpDir(.{});
|
||||
errdefer self.tmp.cleanup();
|
||||
|
||||
var path_buf: [256]u8 = undefined;
|
||||
const path = try std.fmt.bufPrintZ(
|
||||
&path_buf,
|
||||
".zig-cache/tmp/{s}/querylog.db",
|
||||
.{self.tmp.sub_path},
|
||||
);
|
||||
|
||||
self.writer = try db.Db.open(path, .{ .mode = .read_write_create });
|
||||
errdefer self.writer.close();
|
||||
try db.applyPragmas(&self.writer, .{});
|
||||
try self.writer.exec(querylog_schema.ddl);
|
||||
// The DDL stamps `created_at` from the wall clock, and the watermark
|
||||
// with it. These tests work over a fixed 2023 window, so a 2026
|
||||
// watermark would report every one of them as uncovered and the prune
|
||||
// below would not move it.
|
||||
try self.writer.exec(
|
||||
"UPDATE querylog_meta SET created_at = 1600000000, available_since = 1600000000 WHERE id = 1",
|
||||
);
|
||||
|
||||
self.reader = try db.Db.open(path, .{ .mode = .read_write_existing });
|
||||
errdefer self.reader.close();
|
||||
try db.applyPragmas(&self.reader, .{});
|
||||
|
||||
self.state = .{ .gpa = gpa, .querylog_db = &self.reader };
|
||||
self.arena_state = .init(gpa);
|
||||
}
|
||||
|
||||
fn deinit(self: *CacheFixture) void {
|
||||
self.arena_state.deinit();
|
||||
self.state.overview_cache.deinit(self.state.gpa);
|
||||
self.reader.close();
|
||||
self.writer.close();
|
||||
self.tmp.cleanup();
|
||||
self.threaded.deinit();
|
||||
}
|
||||
|
||||
fn io(self: *CacheFixture) std.Io {
|
||||
return self.threaded.io();
|
||||
}
|
||||
|
||||
fn body(self: *CacheFixture, period: Period, span: Window) db.Error![]const u8 {
|
||||
return cachedBody(
|
||||
&self.state,
|
||||
self.io(),
|
||||
&self.reader,
|
||||
self.arena_state.allocator(),
|
||||
period,
|
||||
span,
|
||||
);
|
||||
}
|
||||
|
||||
/// One row through the writer connection, which commits and so moves the
|
||||
/// reader's `PRAGMA data_version`.
|
||||
fn log(self: *CacheFixture, timestamp: i64) !void {
|
||||
var batch = try queries_repo.BatchWriter.init(self.state.gpa, &self.writer);
|
||||
defer batch.deinit();
|
||||
try writeRow(&batch, timestamp, false, false);
|
||||
}
|
||||
};
|
||||
|
||||
test "a cache hit answers without opening a read transaction" {
|
||||
var fx: CacheFixture = undefined;
|
||||
try fx.init(testing.allocator);
|
||||
defer fx.deinit();
|
||||
|
||||
const span = window(.@"1h", 1_700_000_000);
|
||||
try fx.log(span.since + 10);
|
||||
|
||||
const first = try fx.body(.@"1h", span);
|
||||
try testing.expect(first.len > 0);
|
||||
|
||||
// A hit never reaches the database, so a fault armed on the next commit is
|
||||
// never spent — and the bytes are the stored ones, not a rebuild's.
|
||||
db.read_tx_faults.failNextCommit();
|
||||
const second = try fx.body(.@"1h", span);
|
||||
try testing.expectEqualStrings(first, second);
|
||||
try testing.expect(first.ptr != second.ptr);
|
||||
|
||||
// Spend the armed fault so it cannot leak into a later test. A miss does
|
||||
// reach the database, so this one trips.
|
||||
db.read_tx_faults.beginCapture();
|
||||
defer _ = db.read_tx_faults.endCapture();
|
||||
try testing.expectError(error.Internal, fx.body(.@"24h", window(.@"24h", 1_700_000_000)));
|
||||
}
|
||||
|
||||
test "a commit on another connection invalidates the cached body" {
|
||||
var fx: CacheFixture = undefined;
|
||||
try fx.init(testing.allocator);
|
||||
defer fx.deinit();
|
||||
|
||||
const span = window(.@"1h", 1_700_000_000);
|
||||
try fx.log(span.since + 10);
|
||||
const before = try fx.body(.@"1h", span);
|
||||
|
||||
try fx.log(span.since + 20);
|
||||
const after = try fx.body(.@"1h", span);
|
||||
try testing.expect(!std.mem.eql(u8, before, after));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, after, 1, "\"queries\":2"));
|
||||
}
|
||||
|
||||
test "a retention prune through another connection replaces the body and the watermark" {
|
||||
var fx: CacheFixture = undefined;
|
||||
try fx.init(testing.allocator);
|
||||
defer fx.deinit();
|
||||
|
||||
const span = window(.@"1h", 1_700_000_000);
|
||||
try fx.log(span.since + 10);
|
||||
const before = try fx.body(.@"1h", span);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, before, 1, "\"queries\":1"));
|
||||
|
||||
// Past the whole window: the row goes and the watermark advances, and both
|
||||
// halves of the response must move together.
|
||||
_ = try queries_repo.pruneOlderThan(&fx.writer, span.until);
|
||||
const after = try fx.body(.@"1h", span);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, after, 1, "\"queries\":0"));
|
||||
|
||||
var watermark_buf: [64]u8 = undefined;
|
||||
const watermark = try std.fmt.bufPrint(
|
||||
&watermark_buf,
|
||||
"\"available_since\":{d}",
|
||||
.{span.until},
|
||||
);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, after, 1, watermark));
|
||||
}
|
||||
|
||||
test "a window roll rebuilds even with the data unchanged" {
|
||||
var fx: CacheFixture = undefined;
|
||||
try fx.init(testing.allocator);
|
||||
defer fx.deinit();
|
||||
|
||||
const now: i64 = 1_700_000_000;
|
||||
const first = try fx.body(.@"1h", window(.@"1h", now));
|
||||
// One bucket later: same data, a different window, and so a different body.
|
||||
const rolled = try fx.body(.@"1h", window(.@"1h", now + 60));
|
||||
try testing.expect(!std.mem.eql(u8, first, rolled));
|
||||
|
||||
// The slot now holds the rolled window; asking for the earlier one again
|
||||
// rebuilds rather than answering from a key that no longer matches.
|
||||
const again = try fx.body(.@"1h", window(.@"1h", now));
|
||||
try testing.expectEqualStrings(first, again);
|
||||
}
|
||||
|
||||
test "a failed commit installs nothing and leaves the stored entry alone" {
|
||||
var fx: CacheFixture = undefined;
|
||||
try fx.init(testing.allocator);
|
||||
defer fx.deinit();
|
||||
|
||||
const span = window(.@"1h", 1_700_000_000);
|
||||
try fx.log(span.since + 10);
|
||||
const stored = try fx.body(.@"1h", span);
|
||||
|
||||
// A commit that fails on a rebuild: the key has moved, so this is a miss.
|
||||
try fx.log(span.since + 20);
|
||||
db.read_tx_faults.failNextCommit();
|
||||
db.read_tx_faults.beginCapture();
|
||||
try testing.expectError(error.Internal, fx.body(.@"1h", span));
|
||||
try testing.expectEqual(@as(usize, 1), db.read_tx_faults.endCapture());
|
||||
|
||||
// Nothing was published under the new key: the next request rebuilds and
|
||||
// sees the second row, rather than being served the failed read's work or
|
||||
// the first row's body under a key that now describes two.
|
||||
const rebuilt = try fx.body(.@"1h", span);
|
||||
try testing.expect(!std.mem.eql(u8, stored, rebuilt));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, rebuilt, 1, "\"queries\":2"));
|
||||
}
|
||||
@@ -281,7 +281,7 @@ fn openLog() !db.Db {
|
||||
/// upstream answer carries one. A fixture that broke those ties would let a
|
||||
/// serializer regression pass here and fail on real rows.
|
||||
fn seed(database: *db.Db, count: usize) !void {
|
||||
var writer = try queries_repo.BatchWriter.init(database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, database);
|
||||
defer writer.deinit();
|
||||
var rows: [16]queries_repo.Row = undefined;
|
||||
for (rows[0..count], 0..) |*row, i| {
|
||||
|
||||
@@ -1,574 +0,0 @@
|
||||
//! The five period endpoints: `GET /api/stats` and `/api/stats/timeseries`
|
||||
//! (ruling 13), and `/api/stats/types`, `/api/stats/routes` and
|
||||
//! `/api/stats/clients` (milestone 30).
|
||||
//!
|
||||
//! One period grammar, four widths, and one window shared by all five: a
|
||||
//! request for the same period gets the same `since`/`until` from every
|
||||
//! endpoint, so the totals describe exactly the span the charts draw rather
|
||||
//! than a neighbouring one.
|
||||
//!
|
||||
//! That is window coherence, not identical counts. Each endpoint is its own
|
||||
//! request against its own snapshot, so queries logged between two of them move
|
||||
//! one panel and not the other. Only a box with nothing writing to it — a test
|
||||
//! — can expect the breakdowns to sum to the totals exactly.
|
||||
//!
|
||||
//! Buckets are aligned to the UTC grid, not to the moment of the request. Every
|
||||
//! width divides a day, so flooring the current time to a multiple of the width
|
||||
//! puts each bucket on the same boundary a human reads off a clock, and two
|
||||
//! requests a second apart return the same bucket starts. The last bucket is
|
||||
//! the one in progress; it fills as the period runs.
|
||||
//!
|
||||
//! The aggregates run on the web task's own query-log connection (m7 ruling 21),
|
||||
//! which every connection task shares. SQLite's serialized mode makes one call
|
||||
//! safe; it does not make a transaction safe, so `WebState.querylog_lock` covers
|
||||
//! the whole read and a second BEGIN can never land inside the first. Each
|
||||
//! response takes one deferred read transaction, so its aggregate and the
|
||||
//! `coverage` beside it describe one database state: retention cannot prune
|
||||
//! between them and hand a client pre-prune rows tagged with a post-prune
|
||||
//! watermark. Deferred, not `db.Tx`'s BEGIN IMMEDIATE, which would stall the
|
||||
//! logger and retention behind an HTTP response.
|
||||
//!
|
||||
//! The lock is released before the response is written: the body is already
|
||||
//! built in the request arena, and holding a database lock across a socket
|
||||
//! write would let one slow client serialize every other reader.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const coverage = @import("../coverage.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const log = std.log.scoped(.web_stats);
|
||||
|
||||
/// The four periods ruling 13 defines. The tag names are the wire spellings.
|
||||
pub const Period = enum {
|
||||
@"1h",
|
||||
@"24h",
|
||||
@"7d",
|
||||
@"30d",
|
||||
|
||||
pub fn parse(text: []const u8) ?Period {
|
||||
return std.meta.stringToEnum(Period, text);
|
||||
}
|
||||
|
||||
/// Ruling 13: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h.
|
||||
pub fn bucketSeconds(self: Period) u32 {
|
||||
return switch (self) {
|
||||
.@"1h" => 60,
|
||||
.@"24h" => 30 * 60,
|
||||
.@"7d" => 60 * 60,
|
||||
.@"30d" => 6 * 60 * 60,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn bucketCount(self: Period) u32 {
|
||||
return switch (self) {
|
||||
.@"1h" => 60,
|
||||
.@"24h" => 48,
|
||||
.@"7d" => 168,
|
||||
.@"30d" => 120,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn label(self: Period) []const u8 {
|
||||
return @tagName(self);
|
||||
}
|
||||
};
|
||||
|
||||
pub const default_period: Period = .@"24h";
|
||||
|
||||
/// The widest period's bucket count, so one stack array serves every request.
|
||||
pub const max_buckets = 168;
|
||||
|
||||
comptime {
|
||||
for (std.enums.values(Period)) |period| {
|
||||
std.debug.assert(period.bucketCount() <= max_buckets);
|
||||
// The UTC alignment argument holds only while every width divides a day.
|
||||
std.debug.assert(86_400 % period.bucketSeconds() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub const Window = struct {
|
||||
/// Inclusive, on the bucket grid.
|
||||
since: i64,
|
||||
/// Exclusive: the end of the bucket that `now` falls in.
|
||||
until: i64,
|
||||
bucket_seconds: u32,
|
||||
bucket_count: u32,
|
||||
};
|
||||
|
||||
pub fn window(period: Period, now_unix: i64) Window {
|
||||
const width: i64 = period.bucketSeconds();
|
||||
const count: i64 = period.bucketCount();
|
||||
const until = @divFloor(now_unix, width) * width + width;
|
||||
return .{
|
||||
.since = until - width * count,
|
||||
.until = until,
|
||||
.bucket_seconds = period.bucketSeconds(),
|
||||
.bucket_count = period.bucketCount(),
|
||||
};
|
||||
}
|
||||
|
||||
pub const TotalsBody = struct {
|
||||
period: []const u8,
|
||||
since: i64,
|
||||
until: i64,
|
||||
queries: u64,
|
||||
blocked: u64,
|
||||
clients: u64,
|
||||
avg_response_time_us: ?i64,
|
||||
/// Judged against `since`, which is the window this body reports on — so a
|
||||
/// dashboard can say "history starts here" instead of charting a pruned
|
||||
/// stretch as a quiet one.
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
|
||||
pub const TimeseriesBody = struct {
|
||||
period: []const u8,
|
||||
since: i64,
|
||||
until: i64,
|
||||
bucket_seconds: u32,
|
||||
buckets: []const queries_repo.Bucket,
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
|
||||
pub const TypesBody = struct {
|
||||
period: []const u8,
|
||||
since: i64,
|
||||
until: i64,
|
||||
types: []const queries_repo.TypeCount,
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
|
||||
pub const RoutesBody = struct {
|
||||
period: []const u8,
|
||||
since: i64,
|
||||
until: i64,
|
||||
routes: []const queries_repo.RouteCount,
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
|
||||
pub const ClientsBody = struct {
|
||||
period: []const u8,
|
||||
since: i64,
|
||||
until: i64,
|
||||
bucket_seconds: u32,
|
||||
clients: []const queries_repo.ClientSeries,
|
||||
other: []const u64,
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
|
||||
/// Everything one response reads from the query log, so the caller can end the
|
||||
/// transaction and drop the lock before it serializes anything.
|
||||
fn Read(comptime T: type) type {
|
||||
return struct {
|
||||
data: T,
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
}
|
||||
|
||||
const ReadScope = server.QuerylogRead;
|
||||
|
||||
fn readTotals(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
span: Window,
|
||||
) db.Error!Read(queries_repo.StatsTotals) {
|
||||
var scope = try ReadScope.open(state, io, database);
|
||||
errdefer scope.abort();
|
||||
const read: Read(queries_repo.StatsTotals) = .{
|
||||
.data = try queries_repo.statsTotals(database, span.since, span.until),
|
||||
.coverage = try coverage.read(database, span.since),
|
||||
};
|
||||
try scope.commit();
|
||||
return read;
|
||||
}
|
||||
|
||||
fn readTimeseries(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
span: Window,
|
||||
out: []queries_repo.Bucket,
|
||||
) db.Error!Read(usize) {
|
||||
var scope = try ReadScope.open(state, io, database);
|
||||
errdefer scope.abort();
|
||||
const read: Read(usize) = .{
|
||||
.data = try queries_repo.timeseries(database, span.since, span.bucket_seconds, out),
|
||||
.coverage = try coverage.read(database, span.since),
|
||||
};
|
||||
try scope.commit();
|
||||
return read;
|
||||
}
|
||||
|
||||
fn readTypes(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
arena: std.mem.Allocator,
|
||||
span: Window,
|
||||
) db.Error!Read([]const queries_repo.TypeCount) {
|
||||
var scope = try ReadScope.open(state, io, database);
|
||||
errdefer scope.abort();
|
||||
const list = try queries_repo.statsTypes(database, arena, span.since, span.until);
|
||||
const read: Read([]const queries_repo.TypeCount) = .{
|
||||
.data = list.items,
|
||||
.coverage = try coverage.read(database, span.since),
|
||||
};
|
||||
try scope.commit();
|
||||
return read;
|
||||
}
|
||||
|
||||
fn readRoutes(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
arena: std.mem.Allocator,
|
||||
span: Window,
|
||||
) db.Error!Read([]const queries_repo.RouteCount) {
|
||||
var scope = try ReadScope.open(state, io, database);
|
||||
errdefer scope.abort();
|
||||
const list = try queries_repo.statsRoutes(database, arena, span.since, span.until);
|
||||
const read: Read([]const queries_repo.RouteCount) = .{
|
||||
.data = list.items,
|
||||
.coverage = try coverage.read(database, span.since),
|
||||
};
|
||||
try scope.commit();
|
||||
return read;
|
||||
}
|
||||
|
||||
fn readClients(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
arena: std.mem.Allocator,
|
||||
span: Window,
|
||||
) db.Error!Read(queries_repo.ClientsBreakdown) {
|
||||
var scope = try ReadScope.open(state, io, database);
|
||||
errdefer scope.abort();
|
||||
const read: Read(queries_repo.ClientsBreakdown) = .{
|
||||
.data = try queries_repo.statsClients(
|
||||
database,
|
||||
arena,
|
||||
span.since,
|
||||
span.bucket_seconds,
|
||||
span.bucket_count,
|
||||
),
|
||||
.coverage = try coverage.read(database, span.since),
|
||||
};
|
||||
try scope.commit();
|
||||
return read;
|
||||
}
|
||||
|
||||
pub fn totals(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const period = periodParam(request.query) catch return badPeriod(request);
|
||||
const database = state.querylog_db orelse return unavailable(request);
|
||||
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
|
||||
|
||||
const read = readTotals(state, io, database, span) catch |err| {
|
||||
return internal(request, "stats totals", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, TotalsBody{
|
||||
.period = period.label(),
|
||||
.since = span.since,
|
||||
.until = span.until,
|
||||
.queries = read.data.queries,
|
||||
.blocked = read.data.blocked,
|
||||
.clients = read.data.distinct_clients,
|
||||
.avg_response_time_us = read.data.avg_response_time_us,
|
||||
.coverage = read.coverage,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn timeseries(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const period = periodParam(request.query) catch return badPeriod(request);
|
||||
const database = state.querylog_db orelse return unavailable(request);
|
||||
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
|
||||
|
||||
var buckets: [max_buckets]queries_repo.Bucket = undefined;
|
||||
const out = buckets[0..span.bucket_count];
|
||||
const read = readTimeseries(state, io, database, span, out) catch |err| {
|
||||
return internal(request, "stats timeseries", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, TimeseriesBody{
|
||||
.period = period.label(),
|
||||
.since = span.since,
|
||||
.until = span.until,
|
||||
.bucket_seconds = span.bucket_seconds,
|
||||
.buckets = out[0..read.data],
|
||||
.coverage = read.coverage,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn types(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const period = periodParam(request.query) catch return badPeriod(request);
|
||||
const database = state.querylog_db orelse return unavailable(request);
|
||||
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
|
||||
|
||||
const read = readTypes(state, io, database, request.arena, span) catch |err| {
|
||||
return internal(request, "stats types", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, TypesBody{
|
||||
.period = period.label(),
|
||||
.since = span.since,
|
||||
.until = span.until,
|
||||
.types = read.data,
|
||||
.coverage = read.coverage,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn routes(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const period = periodParam(request.query) catch return badPeriod(request);
|
||||
const database = state.querylog_db orelse return unavailable(request);
|
||||
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
|
||||
|
||||
const read = readRoutes(state, io, database, request.arena, span) catch |err| {
|
||||
return internal(request, "stats routes", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, RoutesBody{
|
||||
.period = period.label(),
|
||||
.since = span.since,
|
||||
.until = span.until,
|
||||
.routes = read.data,
|
||||
.coverage = read.coverage,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn clients(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const period = periodParam(request.query) catch return badPeriod(request);
|
||||
const database = state.querylog_db orelse return unavailable(request);
|
||||
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
|
||||
|
||||
const read = readClients(state, io, database, request.arena, span) catch |err| {
|
||||
return internal(request, "stats clients", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, ClientsBody{
|
||||
.period = period.label(),
|
||||
.since = span.since,
|
||||
.until = span.until,
|
||||
.bucket_seconds = span.bucket_seconds,
|
||||
.clients = read.data.clients,
|
||||
.other = read.data.other,
|
||||
.coverage = read.coverage,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub const PeriodError = error{BadPeriod};
|
||||
|
||||
/// An absent `period` is the default; anything else it cannot read is a 400,
|
||||
/// never a silent fallback — a typo must not return a window nobody asked for.
|
||||
fn periodParam(query: []const u8) PeriodError!Period {
|
||||
var buf: [8]u8 = undefined;
|
||||
const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod;
|
||||
const text = found orelse return default_period;
|
||||
return Period.parse(text) orelse error.BadPeriod;
|
||||
}
|
||||
|
||||
fn badPeriod(request: *http_util.Request) http_util.HandlerError!void {
|
||||
return http_util.respondError(request, .bad_request, "period must be one of 1h, 24h, 7d, 30d");
|
||||
}
|
||||
|
||||
fn unavailable(request: *http_util.Request) http_util.HandlerError!void {
|
||||
return http_util.respondError(request, .service_unavailable, "query log unavailable");
|
||||
}
|
||||
|
||||
/// The one thing this file logs. A failed aggregate is a fault in the box, not
|
||||
/// a property of the request, and the client is told nothing beyond "internal
|
||||
/// error" (ruling 8, PLAN §19).
|
||||
fn internal(
|
||||
request: *http_util.Request,
|
||||
what: []const u8,
|
||||
err: db.Error,
|
||||
) http_util.HandlerError!void {
|
||||
log.warn("{s} failed: {s}", .{ what, @errorName(err) });
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const querylog_schema = @import("../../storage/querylog_schema.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
test "the period grammar accepts exactly the four spellings" {
|
||||
try testing.expectEqual(Period.@"1h", Period.parse("1h").?);
|
||||
try testing.expectEqual(Period.@"24h", Period.parse("24h").?);
|
||||
try testing.expectEqual(Period.@"7d", Period.parse("7d").?);
|
||||
try testing.expectEqual(Period.@"30d", Period.parse("30d").?);
|
||||
try testing.expectEqual(@as(?Period, null), Period.parse("12h"));
|
||||
try testing.expectEqual(@as(?Period, null), Period.parse("1H"));
|
||||
try testing.expectEqual(@as(?Period, null), Period.parse(""));
|
||||
}
|
||||
|
||||
test "an absent period defaults and a bad one is rejected" {
|
||||
try testing.expectEqual(default_period, try periodParam(""));
|
||||
try testing.expectEqual(default_period, try periodParam("limit=5"));
|
||||
try testing.expectEqual(Period.@"7d", try periodParam("period=7d"));
|
||||
try testing.expectError(error.BadPeriod, periodParam("period=12h"));
|
||||
try testing.expectError(error.BadPeriod, periodParam("period=%2"));
|
||||
// Longer than any spelling: rejected rather than truncated to "1h".
|
||||
try testing.expectError(error.BadPeriod, periodParam("period=1hhhhhhhhhh"));
|
||||
}
|
||||
|
||||
test "each period spans its own bucket width times its count" {
|
||||
for (std.enums.values(Period)) |period| {
|
||||
const span = window(period, 1_700_000_000);
|
||||
const width: i64 = period.bucketSeconds();
|
||||
try testing.expectEqual(width * @as(i64, period.bucketCount()), span.until - span.since);
|
||||
}
|
||||
}
|
||||
|
||||
test "the window sits on the UTC grid and ends with the bucket in progress" {
|
||||
// 2023-11-14T22:13:20Z, which is not on any bucket boundary.
|
||||
const now: i64 = 1_700_000_000;
|
||||
const span = window(.@"24h", now);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), @rem(span.since, 1800));
|
||||
try testing.expectEqual(@as(i64, 0), @rem(span.until, 1800));
|
||||
try testing.expect(span.until > now);
|
||||
try testing.expect(span.until - now <= 1800);
|
||||
try testing.expectEqual(@as(u32, 48), span.bucket_count);
|
||||
}
|
||||
|
||||
test "two requests inside one bucket see the same window" {
|
||||
// A bucket boundary, so the offsets below stay inside one minute.
|
||||
const boundary: i64 = 1_700_000_000 - @rem(1_700_000_000, 60);
|
||||
const first = window(.@"1h", boundary);
|
||||
const second = window(.@"1h", boundary + 59);
|
||||
try testing.expectEqual(first.since, second.since);
|
||||
try testing.expectEqual(first.until, second.until);
|
||||
|
||||
const next = window(.@"1h", boundary + 60);
|
||||
try testing.expectEqual(first.until + 60, next.until);
|
||||
}
|
||||
|
||||
test "a timestamp exactly on a boundary starts a new bucket" {
|
||||
const span = window(.@"7d", 1_700_000_000 - 1_700_000_000 % 3600);
|
||||
try testing.expectEqual(@as(i64, 0), @rem(span.since, 3600));
|
||||
try testing.expectEqual(@as(u32, 168), span.bucket_count);
|
||||
}
|
||||
|
||||
fn openLog() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
try database.exec(querylog_schema.ddl);
|
||||
return database;
|
||||
}
|
||||
|
||||
fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, cached: ?bool) !void {
|
||||
const rows = [_]queries_repo.Row{.{
|
||||
.timestamp = timestamp,
|
||||
.domain = "example.com",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = blocked,
|
||||
.response_time_us = 1000,
|
||||
.cache_hit = cached,
|
||||
.upstream = null,
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = if (blocked) .block else .allow,
|
||||
.policy_reason = if (blocked) .blocklist_domain else .no_match,
|
||||
.matched = null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = if (blocked) .blocked else .upstream,
|
||||
.forward_zone = null,
|
||||
}};
|
||||
try writer.writeBatch(&rows);
|
||||
}
|
||||
|
||||
test "the totals and the buckets agree over the same window" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
const now: i64 = 1_700_000_000;
|
||||
const span = window(.@"1h", now);
|
||||
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
defer writer.deinit();
|
||||
// One row in the first bucket, two in the last, one just outside.
|
||||
try writeRow(&writer, span.since, false, false);
|
||||
try writeRow(&writer, span.until - 1, true, false);
|
||||
try writeRow(&writer, span.until - 2, false, true);
|
||||
try writeRow(&writer, span.since - 1, false, false);
|
||||
|
||||
const result = try queries_repo.statsTotals(&database, span.since, span.until);
|
||||
try testing.expectEqual(@as(u64, 3), result.queries);
|
||||
try testing.expectEqual(@as(u64, 1), result.blocked);
|
||||
try testing.expectEqual(@as(u64, 1), result.distinct_clients);
|
||||
try testing.expectEqual(@as(?i64, 1000), result.avg_response_time_us);
|
||||
|
||||
var buckets: [max_buckets]queries_repo.Bucket = undefined;
|
||||
const out = buckets[0..span.bucket_count];
|
||||
const written = try queries_repo.timeseries(&database, span.since, span.bucket_seconds, out);
|
||||
try testing.expectEqual(@as(usize, 60), written);
|
||||
|
||||
var summed: u64 = 0;
|
||||
var blocked: u64 = 0;
|
||||
for (out) |bucket| {
|
||||
summed += bucket.queries;
|
||||
blocked += bucket.blocked;
|
||||
}
|
||||
try testing.expectEqual(result.queries, summed);
|
||||
try testing.expectEqual(result.blocked, blocked);
|
||||
|
||||
try testing.expectEqual(span.since, out[0].ts);
|
||||
try testing.expectEqual(@as(u64, 1), out[0].queries);
|
||||
try testing.expectEqual(@as(u64, 2), out[59].queries);
|
||||
try testing.expectEqual(span.until - span.bucket_seconds, out[59].ts);
|
||||
}
|
||||
|
||||
test "an empty window reports zeros with a null mean" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
const span = window(.@"30d", 1_700_000_000);
|
||||
const result = try queries_repo.statsTotals(&database, span.since, span.until);
|
||||
try testing.expectEqual(@as(u64, 0), result.queries);
|
||||
try testing.expectEqual(@as(?i64, null), result.avg_response_time_us);
|
||||
|
||||
var buckets: [max_buckets]queries_repo.Bucket = undefined;
|
||||
const out = buckets[0..span.bucket_count];
|
||||
try testing.expectEqual(@as(usize, 120), try queries_repo.timeseries(
|
||||
&database,
|
||||
span.since,
|
||||
span.bucket_seconds,
|
||||
out,
|
||||
));
|
||||
for (out) |bucket| try testing.expectEqual(@as(u64, 0), bucket.queries);
|
||||
}
|
||||
+39
-2
@@ -166,6 +166,12 @@ pub const Sample = struct {
|
||||
udp_listener: ?udp_server.Snapshot = null,
|
||||
tcp_listener: ?tcp_server.Snapshot = null,
|
||||
upstreams: []const UpstreamSample = &.{},
|
||||
/// Exchanges the pool gave up on because the request's own budget ran out.
|
||||
/// Pool-wide rather than per-upstream on purpose: budget exhaustion is
|
||||
/// evidence about the pool, never about an endpoint, so it carries no url
|
||||
/// label and cannot live in `UpstreamSample`. Absent while no pool is
|
||||
/// wired, like every other collaborator.
|
||||
upstream_budget_exhausted_total: ?u64 = null,
|
||||
};
|
||||
|
||||
pub fn handle(
|
||||
@@ -271,7 +277,10 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
if (state.upstreams) |owner| {
|
||||
const generation = owner.acquire(io);
|
||||
defer owner.release(io, generation);
|
||||
if (generation.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
|
||||
if (generation.pool) |pool| {
|
||||
sample.upstreams = try upstreams(pool, io, arena);
|
||||
sample.upstream_budget_exhausted_total = pool.budgetExhaustedTotal();
|
||||
}
|
||||
}
|
||||
|
||||
return sample;
|
||||
@@ -465,6 +474,15 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
|
||||
if (sample.doh_certs != null or sample.dot_certs != null) try renderCerts(w, sample);
|
||||
|
||||
if (sample.upstreams.len != 0) try renderUpstreams(w, sample.upstreams);
|
||||
if (sample.upstream_budget_exhausted_total) |total| {
|
||||
try labeledHead(
|
||||
w,
|
||||
"nxdns_upstream_budget_exhausted_total",
|
||||
"Exchanges that ran out of their own total budget before any upstream answered.",
|
||||
"counter",
|
||||
);
|
||||
try w.print("nxdns_upstream_budget_exhausted_total {d}\n", .{total});
|
||||
}
|
||||
}
|
||||
|
||||
fn renderCerts(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
|
||||
@@ -1540,7 +1558,7 @@ test "the queue families carry what a real pool recorded, through the real snaps
|
||||
.priority = 10,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.sem = .{ .permits = slots.len },
|
||||
.admission = .{ .permits = slots.len },
|
||||
.reuse_recoveries = &recoveries,
|
||||
}};
|
||||
var pool: pool_mod.Pool = .init(&entries, .{}, .{
|
||||
@@ -1662,3 +1680,22 @@ fn fieldIndex(comptime name: []const u8) usize {
|
||||
}
|
||||
@compileError("no such counter: " ++ name);
|
||||
}
|
||||
|
||||
test "the budget-exhausted counter renders pool-wide, without a url label" {
|
||||
const text = try renderToString(testing.allocator, .{ .upstream_budget_exhausted_total = 7 });
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"# TYPE nxdns_upstream_budget_exhausted_total counter\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_budget_exhausted_total 7\n"));
|
||||
|
||||
// No pool, no series: an operator tells "no upstreams wired" from "zero
|
||||
// exhausted budgets" the same way every other collaborator is told.
|
||||
const absent = try renderToString(testing.allocator, .{});
|
||||
defer testing.allocator.free(absent);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, absent, 1, "nxdns_upstream_budget_exhausted_total"));
|
||||
}
|
||||
|
||||
+70
-201
@@ -422,139 +422,29 @@ paths:
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/stats:
|
||||
/api/overview:
|
||||
get:
|
||||
summary: Totals for a period
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Period"
|
||||
responses:
|
||||
"200":
|
||||
description: Totals over the period's UTC-aligned window.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatsTotals"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/stats/timeseries:
|
||||
get:
|
||||
summary: Bucketed counts for a period
|
||||
summary: Everything the Overview page draws, for one period
|
||||
description: |
|
||||
Fixed-width UTC buckets covering the same window `/api/stats`
|
||||
reports for the period: 1h into 60 one-minute buckets, 24h into 48
|
||||
half-hour buckets, 7d into 168 one-hour buckets, 30d into 120
|
||||
six-hour buckets. Empty buckets are zero-filled.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Period"
|
||||
responses:
|
||||
"200":
|
||||
description: The bucket series.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatsTimeseries"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
One response over one read transaction: the period's totals, its
|
||||
fixed-width UTC buckets, the per-client series, the query-type
|
||||
breakdown and the answering-route breakdown, plus the coverage
|
||||
watermark judged against the same window. The panels therefore describe
|
||||
one database state rather than five, so the breakdowns sum to the
|
||||
totals on a quiet box.
|
||||
|
||||
/api/stats/types:
|
||||
get:
|
||||
summary: Query-type breakdown for a period
|
||||
description: |
|
||||
How many queries of each DNS type the period's window holds, over the
|
||||
same UTC-aligned window `/api/stats` reports for. Rows carry the numeric
|
||||
type only: the type-name table lives in the admin, and a second copy
|
||||
here would drift out of agreement with it. `qtype` is nullable in the
|
||||
query log, so the rows that carry no type group into a row of their own
|
||||
rather than vanishing from a breakdown that claims to add up. Ordered by
|
||||
count descending, then type ascending with the null row last. Types
|
||||
absent from the window are absent from the list.
|
||||
Buckets are UTC-aligned and zero-filled: 1h into 60 one-minute buckets,
|
||||
24h into 48 half-hour buckets, 7d into 168 one-hour buckets, 30d into
|
||||
120 six-hour buckets. The last bucket is the one in progress.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Period"
|
||||
responses:
|
||||
"200":
|
||||
description: The type breakdown.
|
||||
description: The period's overview.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatsTypes"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/stats/routes:
|
||||
get:
|
||||
summary: How the period's queries were answered
|
||||
description: |
|
||||
A breakdown by answering route over the same window `/api/stats`
|
||||
reports for. `source` is the answering resolver's identity — the
|
||||
upstream url on `upstream` rows, the zone on `forward_zone` rows, null
|
||||
on every other kind and on rows whose identity the log did not record.
|
||||
It is not the blocklist a block came from. Ordered by count descending,
|
||||
then route ascending, then source ascending with nulls last.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Period"
|
||||
responses:
|
||||
"200":
|
||||
description: The route breakdown.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatsRoutes"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/stats/clients:
|
||||
get:
|
||||
summary: Per-client bucketed counts for a period
|
||||
description: |
|
||||
One zero-filled series per client, bucketed exactly like
|
||||
`/api/stats/timeseries` so the two charts share an x-axis. The eight
|
||||
clients with the most queries in the window are named, ranked by count
|
||||
descending then address ascending; every other client sums into
|
||||
`other`, which is always present and always holds one entry per bucket
|
||||
in the window — including when `clients` is empty, when no client fell
|
||||
outside the named eight, and when the window holds no queries at all.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Period"
|
||||
responses:
|
||||
"200":
|
||||
description: The per-client series.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatsClients"
|
||||
$ref: "#/components/schemas/Overview"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
@@ -2316,31 +2206,6 @@ components:
|
||||
type: integer
|
||||
description: How many resolved events the purge removed; zero when there were none.
|
||||
|
||||
StatsTotals:
|
||||
type: object
|
||||
required: [period, since, until, queries, blocked, clients, avg_response_time_us, coverage]
|
||||
properties:
|
||||
period:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d, 30d]
|
||||
since:
|
||||
type: integer
|
||||
description: Window start, unix seconds, inclusive.
|
||||
until:
|
||||
type: integer
|
||||
description: Window end, unix seconds, exclusive.
|
||||
queries: { type: integer }
|
||||
blocked: { type: integer }
|
||||
clients:
|
||||
type: integer
|
||||
description: Distinct client addresses in the window.
|
||||
avg_response_time_us:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Null when no query in the window recorded a time.
|
||||
coverage:
|
||||
$ref: "#/components/schemas/Coverage"
|
||||
|
||||
Bucket:
|
||||
type: object
|
||||
required: [ts, queries, blocked, cached]
|
||||
@@ -2352,23 +2217,6 @@ components:
|
||||
blocked: { type: integer }
|
||||
cached: { type: integer }
|
||||
|
||||
StatsTimeseries:
|
||||
type: object
|
||||
required: [period, since, until, bucket_seconds, buckets, coverage]
|
||||
properties:
|
||||
period:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d, 30d]
|
||||
since: { type: integer }
|
||||
until: { type: integer }
|
||||
bucket_seconds: { type: integer }
|
||||
buckets:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Bucket"
|
||||
coverage:
|
||||
$ref: "#/components/schemas/Coverage"
|
||||
|
||||
TypeCount:
|
||||
type: object
|
||||
required: [qtype, count]
|
||||
@@ -2381,22 +2229,6 @@ components:
|
||||
recorded no type, not an absent row.
|
||||
count: { type: integer }
|
||||
|
||||
StatsTypes:
|
||||
type: object
|
||||
required: [period, since, until, types, coverage]
|
||||
properties:
|
||||
period:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d, 30d]
|
||||
since: { type: integer }
|
||||
until: { type: integer }
|
||||
types:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TypeCount"
|
||||
coverage:
|
||||
$ref: "#/components/schemas/Coverage"
|
||||
|
||||
RouteCount:
|
||||
type: object
|
||||
required: [route, source, count]
|
||||
@@ -2412,22 +2244,6 @@ components:
|
||||
row recorded no identity.
|
||||
count: { type: integer }
|
||||
|
||||
StatsRoutes:
|
||||
type: object
|
||||
required: [period, since, until, routes, coverage]
|
||||
properties:
|
||||
period:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d, 30d]
|
||||
since: { type: integer }
|
||||
until: { type: integer }
|
||||
routes:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/RouteCount"
|
||||
coverage:
|
||||
$ref: "#/components/schemas/Coverage"
|
||||
|
||||
ClientSeries:
|
||||
type: object
|
||||
required: [client, buckets]
|
||||
@@ -2443,18 +2259,47 @@ components:
|
||||
items:
|
||||
type: integer
|
||||
|
||||
StatsClients:
|
||||
OverviewTotals:
|
||||
type: object
|
||||
required: [period, since, until, bucket_seconds, clients, other, coverage]
|
||||
required: [queries, blocked, clients, avg_response_time_us]
|
||||
properties:
|
||||
queries: { type: integer }
|
||||
blocked: { type: integer }
|
||||
clients:
|
||||
type: integer
|
||||
description: Distinct client addresses in the window.
|
||||
avg_response_time_us:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Null when no query in the window recorded a time.
|
||||
|
||||
Overview:
|
||||
type: object
|
||||
required: [period, since, until, bucket_seconds, totals, buckets, clients, other, types, routes, coverage]
|
||||
properties:
|
||||
period:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d, 30d]
|
||||
since: { type: integer }
|
||||
until: { type: integer }
|
||||
since:
|
||||
type: integer
|
||||
description: Window start, unix seconds, inclusive.
|
||||
until:
|
||||
type: integer
|
||||
description: Window end, unix seconds, exclusive.
|
||||
bucket_seconds: { type: integer }
|
||||
totals:
|
||||
$ref: "#/components/schemas/OverviewTotals"
|
||||
buckets:
|
||||
type: array
|
||||
description: One entry per bucket in the window, zero-filled.
|
||||
items:
|
||||
$ref: "#/components/schemas/Bucket"
|
||||
clients:
|
||||
type: array
|
||||
description: |
|
||||
The eight clients with the most queries in the window, ranked by
|
||||
count descending then address ascending. Every other client sums
|
||||
into `other`.
|
||||
items:
|
||||
$ref: "#/components/schemas/ClientSeries"
|
||||
other:
|
||||
@@ -2466,6 +2311,30 @@ components:
|
||||
eight, and when the window holds no queries at all.
|
||||
items:
|
||||
type: integer
|
||||
types:
|
||||
type: array
|
||||
description: |
|
||||
How many queries of each DNS type the window holds. Rows carry the
|
||||
numeric type only: the type-name table lives in the admin, and a
|
||||
second copy here would drift out of agreement with it. `qtype` is
|
||||
nullable in the query log, so the rows that carry no type group
|
||||
into a row of their own rather than vanishing from a breakdown that
|
||||
claims to add up. Ordered by count descending, then type ascending
|
||||
with the null row last. Types absent from the window are absent
|
||||
from the list.
|
||||
items:
|
||||
$ref: "#/components/schemas/TypeCount"
|
||||
routes:
|
||||
type: array
|
||||
description: |
|
||||
A breakdown by answering route. `source` is the answering
|
||||
resolver's identity — the upstream url on `upstream` rows, the zone
|
||||
on `forward_zone` rows, null on every other kind and on rows whose
|
||||
identity the log did not record. It is not the blocklist a block
|
||||
came from. Ordered by count descending, then route ascending, then
|
||||
source ascending with nulls last.
|
||||
items:
|
||||
$ref: "#/components/schemas/RouteCount"
|
||||
coverage:
|
||||
$ref: "#/components/schemas/Coverage"
|
||||
|
||||
|
||||
+4
-8
@@ -45,11 +45,11 @@ const local = @import("handlers/local.zig");
|
||||
const lookup = @import("handlers/lookup.zig");
|
||||
const metrics = @import("metrics.zig");
|
||||
const openapi = @import("openapi.zig");
|
||||
const overview = @import("handlers/overview.zig");
|
||||
const pause = @import("handlers/pause.zig");
|
||||
const queries = @import("handlers/queries.zig");
|
||||
const rules = @import("handlers/rules.zig");
|
||||
const settings = @import("handlers/settings.zig");
|
||||
const stats = @import("handlers/stats.zig");
|
||||
const upstreams = @import("handlers/upstreams.zig");
|
||||
const version = @import("handlers/version.zig");
|
||||
|
||||
@@ -64,18 +64,14 @@ pub const table: []const router.RouteInfo = &.{
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout },
|
||||
|
||||
// Query log, stats, live stream, lookup.
|
||||
// Query log, overview, live stream, lookup.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
|
||||
// Listed after the literal `live`, which a linear first-match scan reaches
|
||||
// first — though `{id}` would refuse it anyway, since it captures a
|
||||
// positive integer and nothing else.
|
||||
.{ .method = .GET, .pattern = "/api/queries/{id}", .auth = .session, .policy = .read, .handler = queries.detail },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
|
||||
.{ .method = .GET, .pattern = "/api/stats/types", .auth = .session, .policy = .read, .handler = stats.types },
|
||||
.{ .method = .GET, .pattern = "/api/stats/routes", .auth = .session, .policy = .read, .handler = stats.routes },
|
||||
.{ .method = .GET, .pattern = "/api/stats/clients", .auth = .session, .policy = .read, .handler = stats.clients },
|
||||
.{ .method = .GET, .pattern = "/api/overview", .auth = .session, .policy = .read, .handler = overview.handle },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
|
||||
|
||||
// Diagnostics: the operational event log (milestone 27). The two purges are
|
||||
@@ -161,7 +157,7 @@ const std = @import("std");
|
||||
const testing = std.testing;
|
||||
|
||||
test "the table carries every endpoint of the milestone" {
|
||||
try testing.expectEqual(@as(usize, 64), table.len);
|
||||
try testing.expectEqual(@as(usize, 60), table.len);
|
||||
}
|
||||
|
||||
test "no two entries claim the same method and pattern" {
|
||||
|
||||
+78
-1
@@ -175,6 +175,67 @@ pub const UpstreamBuild = struct {
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
};
|
||||
|
||||
/// The Overview response cache: one already-serialized body per period.
|
||||
///
|
||||
/// A slot is valid for exactly one `(window.until, data_version)` pair, so it
|
||||
/// expires both ways a stale Overview can arise — the window rolls onto the
|
||||
/// next bucket, or another connection (the logger, retention) commits and moves
|
||||
/// `PRAGMA data_version`. There is no time-to-live and no background refresh:
|
||||
/// nothing here can serve bytes that describe a database state the reader could
|
||||
/// not have seen.
|
||||
///
|
||||
/// Every field is read and written under `WebState.querylog_lock`, which is
|
||||
/// also what makes the cache single-flight: a second request for the same key
|
||||
/// waits for the first rebuild and then hits. The type carries no lock of its
|
||||
/// own precisely so that nobody can touch it without the one that matters.
|
||||
pub const OverviewCache = struct {
|
||||
/// One per `overview.Period`, indexed by `@intFromEnum`. The handler asserts
|
||||
/// the two counts agree.
|
||||
pub const slot_count = 4;
|
||||
|
||||
const Slot = struct {
|
||||
/// Empty until the first successful build; never a valid empty body,
|
||||
/// since every response carries at least the period and the window.
|
||||
body: []u8 = &.{},
|
||||
until: i64 = 0,
|
||||
data_version: i64 = 0,
|
||||
};
|
||||
|
||||
slots: [slot_count]Slot = @splat(.{}),
|
||||
|
||||
/// The stored bytes for this key, or null. The caller copies them into its
|
||||
/// request arena before releasing the lock: a later rebuild frees this
|
||||
/// allocation.
|
||||
pub fn get(self: *const OverviewCache, period_index: usize, until: i64, data_version: i64) ?[]const u8 {
|
||||
const slot = &self.slots[period_index];
|
||||
if (slot.body.len == 0) return null;
|
||||
if (slot.until != until or slot.data_version != data_version) return null;
|
||||
return slot.body;
|
||||
}
|
||||
|
||||
/// Takes ownership of `body`, which must be a `gpa` allocation, and frees
|
||||
/// whatever the slot held.
|
||||
pub fn put(
|
||||
self: *OverviewCache,
|
||||
gpa: Allocator,
|
||||
period_index: usize,
|
||||
until: i64,
|
||||
data_version: i64,
|
||||
body: []u8,
|
||||
) void {
|
||||
const slot = &self.slots[period_index];
|
||||
gpa.free(slot.body);
|
||||
slot.* = .{ .body = body, .until = until, .data_version = data_version };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *OverviewCache, gpa: Allocator) void {
|
||||
for (&self.slots) |*slot| {
|
||||
gpa.free(slot.body);
|
||||
slot.* = .{};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const WebState = struct {
|
||||
gpa: Allocator,
|
||||
web: model.Web = .{},
|
||||
@@ -272,6 +333,9 @@ pub const WebState = struct {
|
||||
/// the shared connection would fail and a third task's reads would land
|
||||
/// inside someone else's snapshot.
|
||||
querylog_lock: std.Io.Mutex = .init,
|
||||
/// The Overview response cache, guarded by `querylog_lock` above. Whoever
|
||||
/// owns the `WebState` calls `overview_cache.deinit`.
|
||||
overview_cache: OverviewCache = .{},
|
||||
/// The diagnostics event store, which owns a third connection of its own
|
||||
/// and serializes every access — read and write — through its mutex. Null
|
||||
/// when `Store.init` failed, which `/api/health` reports as `unavailable`
|
||||
@@ -350,11 +414,24 @@ pub const QuerylogRead = struct {
|
||||
pub fn open(state: *WebState, io: std.Io, database: *db.Db) db.Error!QuerylogRead {
|
||||
state.querylog_lock.lockUncancelable(io);
|
||||
errdefer state.querylog_lock.unlock(io);
|
||||
var scope = try openLocked(state, io, database);
|
||||
scope.held = true;
|
||||
return scope;
|
||||
}
|
||||
|
||||
/// The transaction alone, for a caller that already holds `querylog_lock`
|
||||
/// and keeps holding it past `commit` — the overview handler, which decides
|
||||
/// its response cache under the same one hold. Calling `open` there would
|
||||
/// deadlock on a mutex the task already owns.
|
||||
///
|
||||
/// The returned scope releases nothing: `commit` and `abort` end the
|
||||
/// transaction and leave the lock to whoever took it.
|
||||
pub fn openLocked(state: *WebState, io: std.Io, database: *db.Db) db.Error!QuerylogRead {
|
||||
return .{
|
||||
.state = state,
|
||||
.io = io,
|
||||
.tx = try db.ReadTx.begin(database),
|
||||
.held = true,
|
||||
.held = false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ const handlers_lookup = @import("handlers/lookup.zig");
|
||||
const handlers_pause = @import("handlers/pause.zig");
|
||||
const handlers_queries = @import("handlers/queries.zig");
|
||||
const handlers_settings = @import("handlers/settings.zig");
|
||||
const handlers_stats = @import("handlers/stats.zig");
|
||||
const handlers_overview = @import("handlers/overview.zig");
|
||||
const handlers_version = @import("handlers/version.zig");
|
||||
|
||||
const Certificate = std.crypto.Certificate;
|
||||
@@ -495,7 +495,7 @@ const Env = struct {
|
||||
.priority = 1,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.sem = .{ .permits = self.pool_slots.len },
|
||||
.admission = .{ .permits = self.pool_slots.len },
|
||||
.reuse_recoveries = &self.pool_recoveries,
|
||||
}};
|
||||
self.pool_owner = .{};
|
||||
@@ -680,6 +680,7 @@ const Env = struct {
|
||||
|
||||
self.state.live_hash.deinit(gpa);
|
||||
self.state.proxies.deinit(gpa);
|
||||
self.state.overview_cache.deinit(gpa);
|
||||
self.tables.deinit(gpa);
|
||||
gpa.destroy(self.hub);
|
||||
self.limiter.deinit();
|
||||
@@ -738,7 +739,7 @@ fn seedQueryLog(database: *db.Db) !void {
|
||||
\\UPDATE querylog_meta SET created_at = 1700000000, available_since = 1700000000 WHERE id = 1
|
||||
);
|
||||
|
||||
var writer = try queries_repo.BatchWriter.init(database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, database);
|
||||
defer writer.deinit();
|
||||
|
||||
var domain_buf: [32]u8 = undefined;
|
||||
@@ -838,7 +839,7 @@ fn seedQueryLog(database: *db.Db) !void {
|
||||
const recent_clients = 3;
|
||||
|
||||
fn seedRecentTraffic(database: *db.Db, now: i64) !void {
|
||||
var writer = try queries_repo.BatchWriter.init(database);
|
||||
var writer = try queries_repo.BatchWriter.init(testing.allocator, database);
|
||||
defer writer.deinit();
|
||||
|
||||
const Shape = struct {
|
||||
@@ -1048,15 +1049,11 @@ const contract = [_]Contract{
|
||||
// Refresh-all before any source row exists: nothing to fetch, 202 anyway.
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
|
||||
|
||||
// Query log, stats, live stream, upstream health.
|
||||
// Query log, overview, live stream, upstream health.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
|
||||
.{ .method = .GET, .pattern = "/api/queries/{id}", .auth = .session, .policy = .read, .target = "/api/queries/27", .status = 200, .check = jsonShape(provenance_view.QueryDetail) },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/types", .auth = .session, .policy = .read, .target = "/api/stats/types?period=1h", .status = 200, .check = jsonShape(handlers_stats.TypesBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/routes", .auth = .session, .policy = .read, .target = "/api/stats/routes?period=1h", .status = 200, .check = jsonShape(handlers_stats.RoutesBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/clients", .auth = .session, .policy = .read, .target = "/api/stats/clients?period=1h", .status = 200, .check = jsonShape(handlers_stats.ClientsBody) },
|
||||
.{ .method = .GET, .pattern = "/api/overview", .auth = .session, .policy = .read, .target = "/api/overview?period=1h", .status = 200, .check = jsonShape(handlers_overview.Body) },
|
||||
|
||||
// Diagnostics. The seeded store holds one active episode (id 1) and one
|
||||
// resolved one, so both the page and the detail answer with real rows.
|
||||
@@ -2587,11 +2584,7 @@ fn detailUnavailable(io: std.Io, env: *Env) anyerror!void {
|
||||
const targets = [_][]const u8{
|
||||
"/api/queries/1",
|
||||
"/api/queries?limit=1",
|
||||
"/api/stats",
|
||||
"/api/stats/timeseries",
|
||||
"/api/stats/types",
|
||||
"/api/stats/routes",
|
||||
"/api/stats/clients",
|
||||
"/api/overview",
|
||||
};
|
||||
for (targets) |target| {
|
||||
try conn.request("GET", target, null, null);
|
||||
@@ -2657,27 +2650,20 @@ fn coverageWalk(io: std.Io, env: *Env) anyerror!void {
|
||||
);
|
||||
try testing.expect(!partial.coverage.complete);
|
||||
|
||||
// The stats endpoints judge the same watermark against their own aligned
|
||||
// window, which for any live period starts well after the seeded rows.
|
||||
try conn.request("GET", "/api/stats?period=1h", null, null);
|
||||
const totals = try std.json.parseFromSliceLeaky(
|
||||
handlers_stats.TotalsBody,
|
||||
// The overview judges the same watermark against its own aligned window,
|
||||
// which for any live period starts well after the seeded rows.
|
||||
try conn.request("GET", "/api/overview?period=1h", null, null);
|
||||
const overview_body = try std.json.parseFromSliceLeaky(
|
||||
handlers_overview.Body,
|
||||
arena,
|
||||
(try conn.receive(&body_buf)).body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
);
|
||||
try testing.expectEqual(seeded_available_since, totals.coverage.available_since);
|
||||
try testing.expectEqual(totals.since >= seeded_available_since, totals.coverage.complete);
|
||||
|
||||
try conn.request("GET", "/api/stats/timeseries?period=1h", null, null);
|
||||
const series = try std.json.parseFromSliceLeaky(
|
||||
handlers_stats.TimeseriesBody,
|
||||
arena,
|
||||
(try conn.receive(&body_buf)).body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
try testing.expectEqual(seeded_available_since, overview_body.coverage.available_since);
|
||||
try testing.expectEqual(
|
||||
overview_body.since >= seeded_available_since,
|
||||
overview_body.coverage.complete,
|
||||
);
|
||||
try testing.expectEqual(totals.since, series.since);
|
||||
try testing.expectEqual(totals.coverage.complete, series.coverage.complete);
|
||||
}
|
||||
|
||||
fn getJson(
|
||||
@@ -2708,33 +2694,43 @@ fn emptyAggregations(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [256 * 1024]u8 = undefined;
|
||||
|
||||
// This environment's only rows are the fixed 2023 seed, so every live
|
||||
// window is empty. The empty bodies are exact, not merely parseable.
|
||||
const types_body = try getJson(handlers_stats.TypesBody, arena, &conn, "/api/stats/types?period=1h", &body_buf);
|
||||
try testing.expectEqualStrings("1h", types_body.period);
|
||||
try testing.expectEqual(@as(usize, 0), types_body.types.len);
|
||||
|
||||
const routes_body = try getJson(handlers_stats.RoutesBody, arena, &conn, "/api/stats/routes?period=1h", &body_buf);
|
||||
try testing.expectEqual(@as(usize, 0), routes_body.routes.len);
|
||||
// window is empty. The empty body is exact, not merely parseable.
|
||||
const body = try getJson(handlers_overview.Body, arena, &conn, "/api/overview?period=1h", &body_buf);
|
||||
try testing.expectEqualStrings("1h", body.period);
|
||||
try testing.expectEqual(@as(u64, 0), body.totals.queries);
|
||||
try testing.expectEqual(@as(?i64, null), body.totals.avg_response_time_us);
|
||||
try testing.expectEqual(@as(usize, 0), body.types.len);
|
||||
try testing.expectEqual(@as(usize, 0), body.routes.len);
|
||||
|
||||
// `other` is present and bucket-count sized even here: a chart must never
|
||||
// have to invent the residual series.
|
||||
const clients = try getJson(handlers_stats.ClientsBody, arena, &conn, "/api/stats/clients?period=1h", &body_buf);
|
||||
try testing.expectEqual(@as(usize, 0), clients.clients.len);
|
||||
try testing.expectEqual(@as(u32, 60), clients.bucket_seconds);
|
||||
try testing.expectEqual(@as(usize, 60), clients.other.len);
|
||||
for (clients.other) |count| try testing.expectEqual(@as(u64, 0), count);
|
||||
try testing.expectEqual(@as(usize, 0), body.clients.len);
|
||||
try testing.expectEqual(@as(u32, 60), body.bucket_seconds);
|
||||
try testing.expectEqual(@as(usize, 60), body.buckets.len);
|
||||
try testing.expectEqual(@as(usize, 60), body.other.len);
|
||||
for (body.other) |count| try testing.expectEqual(@as(u64, 0), count);
|
||||
|
||||
// A window nobody covers is still reported as such, not as a quiet hour.
|
||||
try testing.expectEqual(seeded_available_since, types_body.coverage.available_since);
|
||||
try testing.expect(types_body.coverage.complete);
|
||||
try testing.expectEqual(seeded_available_since, body.coverage.available_since);
|
||||
try testing.expect(body.coverage.complete);
|
||||
|
||||
for ([_][]const u8{ "/api/stats/types", "/api/stats/routes", "/api/stats/clients" }) |path| {
|
||||
var target_buf: [64]u8 = undefined;
|
||||
const target = try std.fmt.bufPrint(&target_buf, "{s}?period=12h", .{path});
|
||||
try conn.request("GET", target, null, null);
|
||||
const bad = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 400), bad.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, bad.body, 1, "period must be one of"));
|
||||
try conn.request("GET", "/api/overview?period=12h", null, null);
|
||||
const bad = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 400), bad.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, bad.body, 1, "period must be one of"));
|
||||
|
||||
// Milestone 36 removed the five per-panel endpoints. They are gone from the
|
||||
// table, not merely unreferenced by the admin, so the server refuses them.
|
||||
for ([_][]const u8{
|
||||
"/api/stats",
|
||||
"/api/stats/timeseries",
|
||||
"/api/stats/types",
|
||||
"/api/stats/routes",
|
||||
"/api/stats/clients",
|
||||
}) |gone| {
|
||||
try conn.request("GET", gone, null, null);
|
||||
const missing = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 404), missing.status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2759,23 +2755,16 @@ fn populatedAggregations(io: std.Io, env: *Env) anyerror!void {
|
||||
|
||||
var body_buf: [256 * 1024]u8 = undefined;
|
||||
|
||||
const totals = try getJson(handlers_stats.TotalsBody, arena, &conn, "/api/stats?period=1h", &body_buf);
|
||||
const series = try getJson(handlers_stats.TimeseriesBody, arena, &conn, "/api/stats/timeseries?period=1h", &body_buf);
|
||||
const types_body = try getJson(handlers_stats.TypesBody, arena, &conn, "/api/stats/types?period=1h", &body_buf);
|
||||
const routes_body = try getJson(handlers_stats.RoutesBody, arena, &conn, "/api/stats/routes?period=1h", &body_buf);
|
||||
const clients = try getJson(handlers_stats.ClientsBody, arena, &conn, "/api/stats/clients?period=1h", &body_buf);
|
||||
const body = try getJson(handlers_overview.Body, arena, &conn, "/api/overview?period=1h", &body_buf);
|
||||
|
||||
// Nothing writes to this box between the five requests, so the window is
|
||||
// one state and conservation is a real assertion rather than a race.
|
||||
try testing.expectEqual(totals.since, series.since);
|
||||
try testing.expectEqual(totals.since, types_body.since);
|
||||
try testing.expectEqual(totals.since, routes_body.since);
|
||||
try testing.expectEqual(totals.since, clients.since);
|
||||
try testing.expect(totals.queries > 0);
|
||||
// One response over one snapshot, so conservation is a property of the
|
||||
// payload rather than of a quiet box between five requests.
|
||||
try testing.expect(body.totals.queries > 0);
|
||||
const totals = body.totals;
|
||||
|
||||
var typed: u64 = 0;
|
||||
var null_qtype_rows: usize = 0;
|
||||
for (types_body.types) |row| {
|
||||
for (body.types) |row| {
|
||||
typed += row.count;
|
||||
if (row.qtype == null) null_qtype_rows += 1;
|
||||
}
|
||||
@@ -2786,7 +2775,7 @@ fn populatedAggregations(io: std.Io, env: *Env) anyerror!void {
|
||||
var routed: u64 = 0;
|
||||
var null_source_upstreams: usize = 0;
|
||||
var named_upstreams: usize = 0;
|
||||
for (routes_body.routes) |row| {
|
||||
for (body.routes) |row| {
|
||||
routed += row.count;
|
||||
if (row.route != .upstream) continue;
|
||||
if (row.source == null) null_source_upstreams += 1 else named_upstreams += 1;
|
||||
@@ -2795,17 +2784,20 @@ fn populatedAggregations(io: std.Io, env: *Env) anyerror!void {
|
||||
try testing.expectEqual(@as(usize, 1), null_source_upstreams);
|
||||
try testing.expectEqual(@as(usize, 2), named_upstreams);
|
||||
|
||||
try testing.expectEqual(@as(usize, recent_clients), clients.clients.len);
|
||||
try testing.expectEqual(series.buckets.len, clients.other.len);
|
||||
for (clients.clients) |entry| try testing.expectEqual(series.buckets.len, entry.buckets.len);
|
||||
try testing.expectEqual(@as(usize, recent_clients), body.clients.len);
|
||||
try testing.expectEqual(body.buckets.len, body.other.len);
|
||||
for (body.clients) |entry| try testing.expectEqual(body.buckets.len, entry.buckets.len);
|
||||
|
||||
// Per bucket, not just over the window: a series off by one bucket would
|
||||
// still sum correctly in total.
|
||||
for (series.buckets, 0..) |bucket, at| {
|
||||
var summed: u64 = clients.other[at];
|
||||
for (clients.clients) |entry| summed += entry.buckets[at];
|
||||
var bucketed: u64 = 0;
|
||||
for (body.buckets, 0..) |bucket, at| {
|
||||
bucketed += bucket.queries;
|
||||
var summed: u64 = body.other[at];
|
||||
for (body.clients) |entry| summed += entry.buckets[at];
|
||||
try testing.expectEqual(bucket.queries, summed);
|
||||
}
|
||||
try testing.expectEqual(totals.queries, bucketed);
|
||||
}
|
||||
|
||||
test "W10 milestone 30: the three breakdowns conserve the totals over one window" {
|
||||
@@ -2826,11 +2818,8 @@ fn hammerQuerylog(io: std.Io, env: *Env) anyerror!void {
|
||||
|
||||
var body_buf: [256 * 1024]u8 = undefined;
|
||||
const targets = [_][]const u8{
|
||||
"/api/stats?period=1h",
|
||||
"/api/stats/timeseries?period=1h",
|
||||
"/api/stats/types?period=1h",
|
||||
"/api/stats/routes?period=1h",
|
||||
"/api/stats/clients?period=1h",
|
||||
"/api/overview?period=1h",
|
||||
"/api/overview?period=24h",
|
||||
"/api/queries?limit=5",
|
||||
"/api/queries/27",
|
||||
};
|
||||
@@ -2875,7 +2864,7 @@ fn failedCommitIsBounded(io: std.Io, env: *Env) anyerror!void {
|
||||
// the connection recovers (the rollback attempt worked, so the next
|
||||
// `BEGIN` is not refused).
|
||||
db.read_tx_faults.failNextCommit();
|
||||
try conn.request("GET", "/api/stats/types?period=1h", null, null);
|
||||
try conn.request("GET", "/api/overview?period=1h", null, null);
|
||||
const failed = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 500), failed.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, failed.body, 1, "internal error"));
|
||||
@@ -2883,16 +2872,13 @@ fn failedCommitIsBounded(io: std.Io, env: *Env) anyerror!void {
|
||||
// Same connection, same shared query-log handle: a request after the fault
|
||||
// is an ordinary 200. This is the assertion the double-unlock bug failed —
|
||||
// it panicked here instead of answering.
|
||||
try conn.request("GET", "/api/stats/types?period=1h", null, null);
|
||||
try conn.request("GET", "/api/overview?period=1h", null, null);
|
||||
const recovered = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), recovered.status);
|
||||
|
||||
// And every other query-log route still works on that connection.
|
||||
for ([_][]const u8{
|
||||
"/api/stats?period=1h",
|
||||
"/api/stats/timeseries?period=1h",
|
||||
"/api/stats/routes?period=1h",
|
||||
"/api/stats/clients?period=1h",
|
||||
"/api/overview?period=24h",
|
||||
"/api/queries?limit=5",
|
||||
"/api/queries/27",
|
||||
}) |target| {
|
||||
@@ -3012,7 +2998,7 @@ fn credentialSweep(
|
||||
// a closed queue is empty, and a zero flush interval makes it commit the
|
||||
// batch it holds rather than wait for company.
|
||||
query_logger.shutdown(io);
|
||||
try query_logger.runWriter(io, &env.querylog_db, null);
|
||||
try query_logger.runWriter(io, testing.allocator, &env.querylog_db, null);
|
||||
try testing.expectEqual(@as(u64, 1), query_logger.rows_written.load(.monotonic));
|
||||
|
||||
var stmt = try env.querylog_db.prepare(
|
||||
@@ -3905,15 +3891,13 @@ test "drift guard c: the health rollup matches the five objects it documents" {
|
||||
try expectSchemaMatches(gpa, handlers_health.Body, "Health");
|
||||
}
|
||||
|
||||
test "drift guard c: the stats schemas match the structs that serialize them" {
|
||||
test "drift guard c: the overview schema matches the struct that serializes it" {
|
||||
// Guard b counts operations and guard a matches paths, so neither noticed
|
||||
// that `cached` outlived the field it documented. This one would have.
|
||||
// It recurses, so `Bucket`, `ClientSeries`, `TypeCount` and `RouteCount`
|
||||
// are held to their schemas here too.
|
||||
const gpa = testing.allocator;
|
||||
try expectSchemaMatches(gpa, handlers_stats.TotalsBody, "StatsTotals");
|
||||
try expectSchemaMatches(gpa, handlers_stats.TimeseriesBody, "StatsTimeseries");
|
||||
try expectSchemaMatches(gpa, handlers_stats.TypesBody, "StatsTypes");
|
||||
try expectSchemaMatches(gpa, handlers_stats.RoutesBody, "StatsRoutes");
|
||||
try expectSchemaMatches(gpa, handlers_stats.ClientsBody, "StatsClients");
|
||||
try expectSchemaMatches(gpa, handlers_overview.Body, "Overview");
|
||||
}
|
||||
|
||||
test "drift guard c: the query-log schemas match the structs that serialize them" {
|
||||
@@ -4094,8 +4078,6 @@ const contract_sample_walk = [_]ContractSample{
|
||||
// a matched pattern, so the golden exercises every nested object rather
|
||||
// than a row of nulls.
|
||||
.{ .name = "get_query_detail", .ts_type = "QueryDetail", .method = "GET", .target = "/api/queries/27", .status = 200 },
|
||||
.{ .name = "get_stats", .ts_type = "StatsTotals", .method = "GET", .target = "/api/stats?period=1h", .status = 200 },
|
||||
.{ .name = "get_stats_timeseries", .ts_type = "StatsTimeseries", .method = "GET", .target = "/api/stats/timeseries?period=1h", .status = 200 },
|
||||
|
||||
// Pause: the GET before the POST, so one sample carries `until: null` and
|
||||
// the other the deadline.
|
||||
@@ -4122,13 +4104,11 @@ const contract_sample_walk = [_]ContractSample{
|
||||
.{ .name = "error_not_found", .ts_type = "ErrorEnvelope", .method = "GET", .target = "/api/nope", .status = 404 },
|
||||
};
|
||||
|
||||
/// The three period aggregations, captured against an environment with live
|
||||
/// traffic in it: over the fixed 2023 seed every one of them would answer with
|
||||
/// an empty array, which describes no field at all.
|
||||
/// The overview, captured against an environment with live traffic in it: over
|
||||
/// the fixed 2023 seed its four breakdowns would every one answer with an empty
|
||||
/// array, which describes no field at all.
|
||||
const stats_sample_walk = [_]ContractSample{
|
||||
.{ .name = "get_stats_types", .ts_type = "StatsTypes", .method = "GET", .target = "/api/stats/types?period=1h", .status = 200 },
|
||||
.{ .name = "get_stats_routes", .ts_type = "StatsRoutes", .method = "GET", .target = "/api/stats/routes?period=1h", .status = 200 },
|
||||
.{ .name = "get_stats_clients", .ts_type = "StatsClients", .method = "GET", .target = "/api/stats/clients?period=1h", .status = 200 },
|
||||
.{ .name = "get_overview", .ts_type = "Overview", .method = "GET", .target = "/api/overview?period=1h", .status = 200 },
|
||||
};
|
||||
|
||||
/// A session-authenticated environment answers this without a cookie.
|
||||
|
||||
Reference in New Issue
Block a user