Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcd9bce16c
|
||
|
|
2b790c4c3e
|
||
|
|
1860ff59f5
|
||
|
|
f168247b33
|
||
|
|
d596fd788a
|
||
|
|
f067742adf
|
||
|
|
a4eb749fa7
|
||
|
|
22abcd9b7b
|
||
|
|
3e57f43e08
|
||
|
|
299d7af99c
|
||
|
|
85b8be50a0
|
||
|
|
e656670dd4
|
||
|
|
2ae0c974a4
|
||
|
|
2525b01893
|
||
|
|
b774b05456
|
||
|
|
d79dd0bbcb
|
||
|
|
039eeeda96
|
||
|
|
c65d92d8f8
|
||
|
|
207252acee
|
||
|
|
f1de80477a
|
||
|
|
3c674966be
|
||
|
|
59d6be98f8
|
||
|
|
317d5dd4f8
|
||
|
|
79578e1d2a
|
||
|
|
d5613ee718
|
||
|
|
09932b2d84
|
||
|
|
272655f60c
|
||
|
|
dd5a9f6ab8
|
||
|
|
72cdbbd113
|
||
|
|
6170571d23
|
||
|
|
c8470724ce
|
||
|
|
7cdb74ec17
|
||
|
|
4367003788
|
||
|
|
728b8d64e1
|
@@ -41,9 +41,20 @@ on:
|
||||
|
||||
env:
|
||||
ZIG_VERSION: "0.16.0"
|
||||
# The bytes of that zig, not just its version string: a distribution package
|
||||
# of the same version is built against a different LLVM and emits different
|
||||
# machine code, so its tarballs hash to something no CI run reproduces.
|
||||
# setup-zig verifies these same bytes by minisign; `zig build cut` checks this
|
||||
# digest before it builds a release.
|
||||
ZIG_TARBALL_SHA256: "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00"
|
||||
# Exact patch, not a floating "24" (milestone-14 ruling 12): the bundled npm
|
||||
# and the emitted bundle change under a floating major.
|
||||
NODE_VERSION: "24.19.0"
|
||||
# The npm the cut runs and the npm CI runs must be one version: npm writes the
|
||||
# admin bundle whose bytes the release hashes are pinned to before CI ever
|
||||
# rebuilds them. setup-node installs the npm that ships with the node above,
|
||||
# so this pin is asserted, not installed.
|
||||
NPM_VERSION: "11.17.0"
|
||||
# There is deliberately no CI_VERSION literal here. Ruling 2 allows the
|
||||
# version to exist in the tag and in build.zig.zon and nowhere else, and
|
||||
# ruling 5 makes verify-dist fail when the version under build disagrees with
|
||||
@@ -126,9 +137,25 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: admin/package-lock.json
|
||||
|
||||
# The bundle is a release input whose hash is pinned before this run
|
||||
# exists, so a runner on a different node or npm must fail here rather
|
||||
# than emit different bytes further down.
|
||||
- name: Assert the pinned Node and npm
|
||||
run: |
|
||||
test "$(node --version)" = "v${NODE_VERSION:?}"
|
||||
test "$(npm --version)" = "${NPM_VERSION:?}"
|
||||
test ! -e /nonexistent/npmrc-user
|
||||
test ! -e /nonexistent/npmrc-global
|
||||
|
||||
# `npm ci` and `npm run build` run under the same normalized environment
|
||||
# the cut builds the bundle in: exactly nine variables, a file mode from
|
||||
# the umask, a C locale, UTC, and a zero build timestamp. Each of those
|
||||
# can move the bytes the release hashes cover. The format, lint,
|
||||
# typecheck and test steps below stay ambient on purpose: only the
|
||||
# bundle's bytes are pinned, and those checks emit nothing that ships.
|
||||
- name: Install dependencies
|
||||
working-directory: admin
|
||||
run: npm ci
|
||||
run: env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global sh -c 'umask 022 && npm ci'
|
||||
|
||||
- name: Check formatting
|
||||
working-directory: admin
|
||||
@@ -148,7 +175,7 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
working-directory: admin
|
||||
run: npm run build
|
||||
run: env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global sh -c 'umask 022 && npm run build'
|
||||
|
||||
# The licence inventory has to cover every package whose bytes ship, and
|
||||
# the lockfile does not answer that question: it lists what could be
|
||||
@@ -191,7 +218,12 @@ jobs:
|
||||
version: ${{ steps.zon-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
# Depth 2, not the default 1: the pin check at the foot of this job runs
|
||||
# only on the commit that changed build.zig.zon, and answering that
|
||||
# question needs HEAD's first parent. No other job here reads a parent.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
|
||||
@@ -243,7 +275,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
zig build dist \
|
||||
-Dversion-string="$CI_VERSION" \
|
||||
-Dgit-commit="$GITHUB_SHA" \
|
||||
-Dadmin-dist=admin-dist-ci \
|
||||
-Doptimize=ReleaseSafe
|
||||
|
||||
@@ -259,10 +290,14 @@ jobs:
|
||||
set -euo pipefail
|
||||
zig build verify-dist \
|
||||
-Dversion-string="$CI_VERSION" \
|
||||
-Dgit-commit="$GITHUB_SHA" \
|
||||
-Dadmin-dist=admin-dist-ci \
|
||||
-Doptimize=ReleaseSafe
|
||||
|
||||
# This upload runs before the pin check below: a pin mismatch is exactly
|
||||
# the failure whose diagnosis needs the built bytes, so they must already
|
||||
# be downloadable when that step fails. The container job's dependency on
|
||||
# the artifact is unchanged.
|
||||
#
|
||||
# deploy/docker/Dockerfile copies both of these trees and nothing else
|
||||
# out of zig-out/dist: the binary comes from dist/bin/<triple>/, and
|
||||
# /LICENSE and /THIRD-PARTY-NOTICES come from the matching dist/stage/
|
||||
@@ -290,6 +325,57 @@ jobs:
|
||||
zig-out/bin/container_check
|
||||
if-no-files-found: error
|
||||
|
||||
# The cut writes the release hashes into flake.nix before it makes the
|
||||
# bump commit, so the bump commit is the one commit whose pins nothing has
|
||||
# verified yet — the tag's own run (release.yml) is the next chance, and by
|
||||
# then the tag is public. This step is that first chance.
|
||||
#
|
||||
# It is deliberately NOT part of verify-dist. Every other commit on master
|
||||
# builds the same build.zig.zon version from a different tree, so its bytes
|
||||
# legitimately differ from the pins and a check there would fail the whole
|
||||
# branch. The commit is identified by the version it declares, not by its
|
||||
# message: a message is a string anyone can write, and the pins follow
|
||||
# the manifest.
|
||||
#
|
||||
# A root commit has no first parent. That is an error rather than a skip:
|
||||
# this repository has history, so `HEAD^` failing means the checkout is
|
||||
# shallower than the depth 2 declared above and the question went
|
||||
# unanswered, which must never read as "nothing to check".
|
||||
#
|
||||
# The predicate is the declared VERSION, not the file: build.zig.zon also
|
||||
# carries the dependency pins, and updating a sqlite or mbedTLS hash
|
||||
# changes the file without cutting a release. Such a commit builds the
|
||||
# same version from a different tree, so its bytes are not the pinned
|
||||
# ones and this check would fail it.
|
||||
#
|
||||
# A commit that rewrites flake.nix runs the check too: a re-pin during a
|
||||
# cut changes the pins without changing the version, and those pins must
|
||||
# be verified before the tag as well.
|
||||
#
|
||||
# HEAD's version is CI_VERSION, parsed out of the working tree by the
|
||||
# container gate tool. The parent's is read with `sed`, because that tool
|
||||
# reads `build.zig.zon` at a fixed path and has no mode for a blob out of
|
||||
# history. An empty parse is a failure, not a bump: it means the manifest
|
||||
# moved and the question went unanswered.
|
||||
- name: Verify the flake pins on a version bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
parent="$(git rev-parse --verify HEAD^)"
|
||||
parent_version="$(git show "$parent":build.zig.zon | sed -n 's/^[[:space:]]*\.version = "\([^"]*\)".*/\1/p')"
|
||||
if [ -z "$parent_version" ]; then
|
||||
echo "cannot read .version out of $parent:build.zig.zon" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$parent_version" != "$CI_VERSION" ] || ! git diff --quiet "$parent" HEAD -- flake.nix; then
|
||||
zig build verify-pins \
|
||||
-Dversion-string="$CI_VERSION" \
|
||||
-Dadmin-dist=admin-dist-ci \
|
||||
-Doptimize=ReleaseSafe
|
||||
else
|
||||
echo "skipped: $GITHUB_SHA declares version $CI_VERSION, $parent already declared $parent_version and flake.nix is unchanged, so it is neither a version bump nor a re-pin and its bytes are not the ones flake.nix pins"
|
||||
fi
|
||||
|
||||
|
||||
container:
|
||||
needs: [package]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -60,8 +60,15 @@ env:
|
||||
REGISTRY_HOST: "git.mial.net"
|
||||
|
||||
ZIG_VERSION: "0.16.0"
|
||||
# The bytes of that zig, not just its version string: a distribution package
|
||||
# of the same version is built against a different LLVM and emits different
|
||||
# machine code, so its tarballs hash to something no CI run reproduces.
|
||||
# setup-zig verifies these same bytes by minisign; `zig build cut` checks this
|
||||
# digest before it builds a release.
|
||||
ZIG_TARBALL_SHA256: "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00"
|
||||
# Exact patch, not a floating "24" (ruling 12).
|
||||
NODE_VERSION: "24.19.0"
|
||||
NPM_VERSION: "11.17.0"
|
||||
|
||||
# The author's commit- and tag-signing key. `git verify-tag` alone proves
|
||||
# only that *some* key in the keyring signed the tag, so the signature's
|
||||
@@ -271,18 +278,29 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: admin/package-lock.json
|
||||
|
||||
# The same pinned toolchain and normalized environment as the frontend
|
||||
# job in gates.yml: the cut pinned the bundle's bytes into flake.nix
|
||||
# before this run existed, and the pin check below compares against them.
|
||||
- name: Assert the pinned Node and npm
|
||||
run: |
|
||||
test "$(node --version)" = "v${NODE_VERSION:?}"
|
||||
test "$(npm --version)" = "${NPM_VERSION:?}"
|
||||
test ! -e /nonexistent/npmrc-user
|
||||
test ! -e /nonexistent/npmrc-global
|
||||
|
||||
# The same normalized environment the cut builds the bundle in: exactly
|
||||
# nine variables, a file mode from the umask, a C locale, UTC and a zero
|
||||
# build timestamp. Each of those can move the bytes the release hashes
|
||||
# cover, and this job's bundle has to reproduce the one the cut pinned.
|
||||
- name: Build the web UI
|
||||
working-directory: admin
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
run: env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global sh -c 'umask 022 && npm ci && npm run build'
|
||||
|
||||
# Step 8.
|
||||
- name: Build the release artifacts
|
||||
run: >
|
||||
zig build dist
|
||||
-Dversion-string="$VERSION"
|
||||
-Dgit-commit="$TAG_COMMIT"
|
||||
-Dadmin-dist=admin/dist
|
||||
-Doptimize=ReleaseSafe
|
||||
|
||||
@@ -290,7 +308,19 @@ jobs:
|
||||
run: >
|
||||
zig build verify-dist
|
||||
-Dversion-string="$VERSION"
|
||||
-Dgit-commit="$TAG_COMMIT"
|
||||
-Dadmin-dist=admin/dist
|
||||
-Doptimize=ReleaseSafe
|
||||
|
||||
# Unconditional, and before the image push, the draft and every upload:
|
||||
# the tag's tree IS the bump commit's tree, so the flake block must pin
|
||||
# these exact bytes and name this exact version. A consumer who resolves
|
||||
# the tag through the flake gets hashes that were written before this run
|
||||
# existed; this is where the claim is proved, while nothing has yet left
|
||||
# the runner.
|
||||
- name: Verify the flake pins
|
||||
run: >
|
||||
zig build verify-pins
|
||||
-Dversion-string="$VERSION"
|
||||
-Dadmin-dist=admin/dist
|
||||
-Doptimize=ReleaseSafe
|
||||
|
||||
|
||||
@@ -4,6 +4,95 @@ 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.18] - 2026-09-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Overview scope pickers no longer sit apart, and a long device name no longer pushes the period picker off a phone screen.** The Device and Period dropdowns fill their wrappers and stand side by side; a long name truncates with an ellipsis in the trigger and stays fully readable in the list.
|
||||
|
||||
## [0.0.17] - 2026-09-08
|
||||
|
||||
### Added
|
||||
|
||||
- **A Nix flake with tag-pinned hashes.** `flake.nix` at the repository root builds `nxdns` for `aarch64-linux` and `x86_64-linux` from the release tarballs, and carries their hashes in a generated block. A consumer pins the flake to a release tag and gets the exact bytes that tag published; `docs/how-to/install-with-nix.md` covers the input, the `nixpkgs` follows line, and Renovate.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The release archive is written by the project's own tool.** `zig build dist` no longer shells out to the runner's `tar` and `gzip`; it writes the tar stream and the gzip container itself, with sorted entries, fixed modes, and zero timestamps. The tarball bytes now depend on the source tree, the compiler, and the admin bundle, and on nothing the host supplies — which is what lets a hash be pinned before CI rebuilds it.
|
||||
- **The cut pins the release hashes before it commits.** `zig build cut` asserts the local Node, npm, and Zig match the versions CI pins, builds the release from the bumped manifest in a normalized environment, writes the resulting hashes into `flake.nix`, and commits that file alongside `build.zig.zon` as one commit. CI reverifies the pins on the bump commit and again on the tag, before anything is uploaded.
|
||||
- **`nxdns version` no longer reports a git commit.** The commit is gone from the command's output, from `GET /api/version`, and from the admin footer, which now shows `nxdns v<version>`. A release identifies itself by version, and the bytes are reproducible from the tag, so a commit embedded in the binary told a reader nothing the tag did not.
|
||||
|
||||
## [0.0.16] - 2026-09-07
|
||||
|
||||
The Overview page is redesigned around the two dashboards people already know — Pi-hole's layout, NextDNS's charts — and every number in the admin is spelled one way.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Overview reads like an analytics dashboard.** Four centred totals (queries, blocked, active clients, and the blocked share), a smooth area chart of total and blocked queries where hovering marks the point and shows its figures beside it, a stacked client-activity chart in eight distinct hues with everything else as one gray band, and a row of cards under them: the cache hit rate as a progress bar, the query types as a single-hue ring, and the upstream breakdown.
|
||||
- **Overview can be scoped to one device.** A device selector sits beside the period selector; both are URL state, so `/overview?period=7d&client=192.0.2.30` is a link to exactly that view. The API takes the same `client` parameter on `GET /api/overview`, and the tile links carry the scope into Activity.
|
||||
- **One formatting contract.** Counts are thousands-grouped, percentages always show two decimals (15.44%, never 15.4%), rates one decimal, and durations their two largest nonzero units ("6d 4h", "12m 5s", "6d 5s"). A test sweeps the sources for any number formatted outside the one module.
|
||||
- **A quieter, more consistent chrome.** One 4px corner radius everywhere, cards with a prominent title and a one-line description, the sidebar grouped into Monitoring and System with a status block at its foot (protection, queries per minute on Overview, uptime), and a top bar in place of the sidebar on narrow screens. The active navigation item and the accent blue are darkened to clear WCAG AA on their washes.
|
||||
|
||||
## [0.0.15] - 2026-08-31
|
||||
|
||||
The Activity page's history filters become a toolbar you can actually use. Filters apply as you set them, the client field stops asking you to remember IP addresses, and the whole admin picks up one coherent icon set.
|
||||
|
||||
### Changed
|
||||
|
||||
- **History filters apply live from a toolbar.** The form-and-Apply-button row is gone. Domain text filters as you type (debounced), result and time are one-click controls, and a custom date range applies as one change. Every filter state is a bookmarkable URL.
|
||||
- **The client filter is a picker, not a text field.** It lists the clients the server knows — named, sorted, multi-select — instead of asking for an exact address. Selected clients show as removable chips (first three, then a count), and a query can filter on up to 32 clients at once; the API accepts the same list.
|
||||
- **One icon set.** Interface glyphs (dropdown carets, checkbox ticks, the search magnifier, status marks, back arrows) are now Phosphor icons instead of a mix of text characters and hand-drawn shapes.
|
||||
- **The admin bundle budget rises from 800,000 to 900,000 bytes.** The client picker, the accessible menu and dialog primitives behind it, and the Phosphor icon components are the arrivals that spend it; the built assets sit at about 840,000 bytes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Accessibility pass over the admin.** Focus-visible rings on every control, labels wired to their inputs, keyboard-reachable menus and dialogs, and visible text where information previously lived only in hover titles.
|
||||
|
||||
## [0.0.14] - 2026-08-28
|
||||
|
||||
Schema changes stop costing you your query history. querylog.db is now version-stamped and migrated in place; the server refuses to start rather than ever reset a healthy file, and the release tooling refuses to ship a schema change that is neither migratable nor explicitly disclosed with recovery steps. Three releases (0.0.6, 0.0.9, 0.0.12) each discarded the log on upgrade; this ends that.
|
||||
|
||||
### Changed
|
||||
|
||||
- **querylog.db is migrated in place.** The file now carries a schema version, and a release that changes the schema ships a migration that runs at startup: one consistent backup (`querylog.db.pre-migrate-<timestamp>`, mode 0600, only the most recent kept), then every step and the version stamp in a single transaction. A failure before the commit rolls back and leaves your file exactly as it was.
|
||||
- **The server refuses instead of resetting.** A querylog.db it cannot use — newer than the binary, older than 0.0.12, or mid-migration failure — is left untouched and the server exits with a clear message instead of setting the file aside and starting an empty log. The exit code (2) tells systemd not to restart-loop a deliberate refusal. Corruption is the only case that still sets a file aside automatically.
|
||||
- **The release gate now enforces the contract.** A schema change cannot be tagged unless it either ships a working migration (proven in CI against a frozen fixture of the previous schema, with shipped migration files locked byte-for-byte once released) or explicitly declares a break — which requires a version bump the server refuses on, a reset disclosure, and step-by-step restore instructions in this file.
|
||||
|
||||
**One hazard to know when downgrading.** The first start under this release restamps querylog.db from the old fingerprint to version 1 (contents untouched). If you later downgrade to 0.0.13 or older, that binary treats the new stamp as a schema mismatch, moves your file aside as `querylog.db.schema-changed-<timestamp>`, and starts an empty log. To recover: return to 0.0.14 or newer, stop the server, move the empty `querylog.db` away and delete its `querylog.db-wal` and `querylog.db-shm` files (leaving them would corrupt the restored file), rename the `.schema-changed-<timestamp>` file back to `querylog.db`, and start.
|
||||
|
||||
## [0.0.13] - 2026-08-27
|
||||
|
||||
The upstream query budget becomes one honest deadline. A busy network no longer blames a healthy standby for running out of time, and a query burst no longer queues invisibly until everything answers SERVFAIL at once.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The per-query upstream budget is now one absolute deadline, spent by everything that blocks.** Waiting for a free slot on a saturated upstream now spends the query's `upstream.total_timeout_ms` budget just like the exchange itself, instead of being invisible to it — under a burst, queries used to wait out their whole budget in the queue and then start attempts they could never finish. An attempt near the end of the budget runs truncated, and when a truncated attempt runs out of time that is evidence about the budget, not the upstream: it no longer counts against that upstream's health or success rate, and the query log no longer names an upstream that was given no fair chance. A field incident produced 279 rows blaming a standby whose health counters read zero for zero; those rows now attribute nothing.
|
||||
- **A query no longer blocks behind a saturated upstream while another has capacity.** Admission sweeps the upstreams in priority order and takes the first free slot; priority now means the order among upstreams that can be admitted right now, and a query blocks only when nothing has capacity — on the highest-priority eligible upstream, bounded by the remaining budget.
|
||||
- **Conditional forward zones spend `upstream.read_timeout_ms` once per query.** A UDP attempt, a truncated answer and the TCP retry now share the one budget instead of taking a fresh one each, so a slow zone resolver can no longer stretch a single query to several times the configured timeout.
|
||||
|
||||
### Added
|
||||
|
||||
- **`nxdns_upstream_budget_exhausted_total`.** A pool-wide counter of queries whose budget ran out — in the queue or mid-attempt — before any upstream answered. It carries no per-upstream label on purpose: running out of budget is a fact about the pool.
|
||||
- **A configuration with more than 64 enabled upstreams is rejected at validation** with a clear message, instead of tripping an internal limit at startup.
|
||||
|
||||
## [0.0.12] - 2026-08-27
|
||||
|
||||
Overview stops re-reading the whole query log. One endpoint, one snapshot, pre-aggregated buckets — a 30-day view now costs the same on a month of history as on a day of it. Read the upgrade note first: it resets your query history.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Upgrading resets your query history.** The query-log schema gains the aggregate tables described below, and `querylog.db` is never migrated: the first start after the upgrade sets the old file aside (kept on disk next to the new one, named with the reason) and begins a fresh log. Settings, groups, blocklists and every other configuration are untouched.
|
||||
- **The Overview is served by one endpoint, `GET /api/overview`.** It replaces `GET /api/stats`, `/api/stats/timeseries`, `/api/stats/types`, `/api/stats/routes` and `/api/stats/clients`, which are gone. The five panels now come from a single database snapshot, so they can no longer disagree with each other, and the page shows one loading and one error state instead of five.
|
||||
- **Query statistics are pre-aggregated as they are written.** The query log now maintains 30-minute aggregate tables in the same transaction that stores the rows, and the 24-hour, 7-day and 30-day views read those instead of scanning every logged query. The cost of opening the Overview no longer grows with the size of the log: measured at three million rows, the 30-day view went from roughly eight-tenths of a second of scanning to under fifty milliseconds, at the price of about ten percent on each background write batch and ~1.5 MB of disk. The server also keeps the most recent response per period in memory and serves repeat polls from it while nothing has changed — until new queries land, retention prunes, or the period's time window rolls forward — so on a quiet network most of the steady 30-second refreshes do no database work at all.
|
||||
|
||||
The Overview charts move to visx and grow up: one hover treatment across all four, honest labels, and maintained d3 math under the app's own rendering.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The Overview charts are drawn with visx.** The hand-written chart layout code is replaced by visx 4.0.0 primitives — maintained d3 math for the scales, ticks, stacking and arcs — while the rendering, colours and themes stay the app's own. The charts read as before, with four behaviour improvements: all four charts now share the same hover treatment (the per-client chart and both donuts gain the tooltip and dimming the query timeline already had, so pointing at a ring segment names it, its count and its share), an open tooltip follows a data refresh instead of showing stale counts, and it retires cleanly when the time window rolls. The admin bundle grows by about 68 KB and stays under its size budget.
|
||||
- **The query timeline's third series is called "Allowed".** What the chart called "Other" is every query that was neither blocked nor served from cache — answered upstream, from a local record or a forward zone — so it is now named for what it is rather than for the subtraction that produces it. It stays on the chart even in a window where nothing was allowed, alongside Blocked and Cached: all three name a kind of answer a query can get, and a period where every query was blocked or cached is worth seeing.
|
||||
- **The client chart drops "Other" in a window where it counted nothing.** That series aggregates the clients outside the top eight, so when it counts nothing there is nothing being aggregated, and a legend entry, a tooltip row and a table column that exist only to say "zero" are noise. The named clients stay even at zero, because a client that went quiet is a fact about the window.
|
||||
|
||||
## [0.0.10] - 2026-08-24
|
||||
|
||||
Configuration goes live: when the database owns the configuration, saving a setting reconfigures the running process instead of asking for a restart. The restart-required set shrinks to the listen sockets and the admin interface switch.
|
||||
|
||||
@@ -85,13 +85,13 @@ Verified: 0.16.0 ships `std.crypto.tls.Client` only. There is no server-side TLS
|
||||
Two SQLite files with opposite write profiles, isolated from each other:
|
||||
|
||||
- **`config.db`** — small, precious, rarely written: groups, clients, prefixes, upstreams, blocklist source metadata, rules, local records, forward zones, settings, schema version.
|
||||
- **`querylog.db`** — high-churn, large, expendable: query log + its own private `domains` dimension table. Client identity stored as **IP text**, not a FK into config — log rows are immutable facts and must not point at mutable config rows. If `querylog.db` is missing or corrupt at startup, rename aside, recreate, keep serving. Log loss is not an outage.
|
||||
- **`querylog.db`** — high-churn, large, expendable: query log + its own private `domains` dimension table. Client identity stored as **IP text**, not a FK into config — log rows are immutable facts and must not point at mutable config rows. If `querylog.db` is missing or corrupt at startup, rename aside, recreate, keep serving — corruption only; a healthy file whose schema this build cannot use refuses the startup instead (§3.7).
|
||||
- No cross-DB references. Retention/VACUUM churn never touches `config.db`; config backup is a copy of a tiny file.
|
||||
|
||||
### 3.7 Upgrades: Auto-Migration (Decision J)
|
||||
|
||||
- `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart. Before v0.1 the list holds one step — the baseline of §11.2, edited in place — because nxdns has no installs and a step exists only to reconcile a database somebody already has.
|
||||
- `querylog.db`: **no migrations.** On schema mismatch: rename aside, recreate fresh.
|
||||
- `querylog.db`: a logical version in `PRAGMA user_version`, migrated **in place** at startup by the same shape of compiled step list, inside one transaction and behind one `querylog.db.pre-migrate-<epoch>` backup (only the newest is kept). A healthy file is never renamed aside: a version this build cannot reach refuses the startup with instructions, and only corruption recreates. A deliberate break is still allowed, but it must be versioned, refused at startup, and disclosed in the changelog — the cut gate enforces that. See `docs/reference/query-log-lifecycle.md`.
|
||||
|
||||
### 3.8 Blocklist Storage (Decision A)
|
||||
|
||||
@@ -238,7 +238,7 @@ src/
|
||||
web/
|
||||
server.zig router.zig auth.zig sse.zig static.zig metrics.zig openapi.zig
|
||||
handlers/
|
||||
auth.zig stats.zig queries.zig clients.zig groups.zig blocklists.zig
|
||||
auth.zig overview.zig queries.zig clients.zig groups.zig blocklists.zig
|
||||
rules.zig local.zig lookup.zig pause.zig settings.zig
|
||||
upstream_health.zig certs.zig health.zig version.zig
|
||||
|
||||
@@ -484,6 +484,8 @@ CREATE INDEX idx_query_log_client ON query_log(client_ip);
|
||||
CREATE INDEX idx_query_log_domain ON query_log(domain_id);
|
||||
```
|
||||
|
||||
The sketch above is the original shape; `src/storage/querylog_schema.zig` is the authority, and the provenance columns milestone 28 added are not repeated here. Beside the raw rows the file carries four projection tables — `bucket_totals`, `bucket_clients`, `bucket_types`, `bucket_routes` — on a 30-minute grain, which is what `GET /api/overview` reads for the 24h, 7d and 30d windows instead of scanning every row. They are maintained by the batch writer and by retention inside the same transaction as the raw rows, so SQLite's transaction is the whole coherence story: no second file, no backfill, no rebuild command. The 1h window is narrower than the grain and takes one raw scan.
|
||||
|
||||
### 11.4 Query Logger
|
||||
|
||||
- In-memory buffer, mutex guarded, hard cap `query_log_buffer_max` (default 10000).
|
||||
@@ -493,7 +495,7 @@ CREATE INDEX idx_query_log_domain ON query_log(domain_id);
|
||||
|
||||
### 11.5 Retention
|
||||
|
||||
Periodic delete of rows older than `retention_days`; scheduled checkpoint/VACUUM on `querylog.db` only.
|
||||
Periodic delete of rows older than `retention_days`, dropping the projection buckets behind the cutoff and recomputing the straddling one in the same transaction; scheduled checkpoint/VACUUM on `querylog.db` only.
|
||||
|
||||
### 11.6 Disk Discipline (cloudflared lesson)
|
||||
|
||||
@@ -536,7 +538,7 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
|
||||
### 13.1 Endpoints
|
||||
|
||||
- `POST /api/auth/login`, `POST /api/auth/logout`
|
||||
- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…`, `GET /api/stats/types?period=…`, `GET /api/stats/routes?period=…`, `GET /api/stats/clients?period=…`
|
||||
- `GET /api/overview?period=…` — every Overview panel in one response over one read transaction
|
||||
- `GET /api/queries` (filter + paginate), `GET /api/queries/live` (SSE, per-IP cap)
|
||||
- `GET/PUT /api/clients/{id}`
|
||||
- `GET/POST/PUT/DELETE /api/groups…`, `/api/blocklists…`, `/api/rules…`, `/api/local-records…`, `/api/forward-zones…`
|
||||
@@ -569,7 +571,7 @@ Requirements: responsive desktop/mobile; route loaders for initial fetch; TanSta
|
||||
- `nxdns check` — validate config, probe upstreams, load each enabled listener's certificate and verify its key pairs with it; exit 2 on failure, 0 with warnings.
|
||||
- `nxdns export [--out file.zon]`
|
||||
- `nxdns import <file.zon> [--force]`
|
||||
- `nxdns version` — app version, Zig version string, git commit. No build date: the version and the commit identify a build exactly, and a date is one more input a reproducible build would have to pin.
|
||||
- `nxdns version` — app version and Zig version string. No commit and no build date: the version names the release, the release tarballs are reproducible from the tag with the pinned toolchain, and `flake.nix` pins their hashes. A commit or a date would be one more build input that reproducibility has to pin.
|
||||
|
||||
---
|
||||
|
||||
@@ -664,7 +666,7 @@ The project publishes released binaries and container images from its own Gitea
|
||||
6. Disk-fill degrades gracefully; no silent log-flood failure mode.
|
||||
7. Web UI + API provide full admin functionality; OpenAPI contract tests green.
|
||||
8. `nxdns export` round-trips via `nxdns import`.
|
||||
9. Query logging, stats, SSE live stream work; querylog.db corruption self-heals.
|
||||
9. Query logging, the overview, SSE live stream work; querylog.db corruption self-heals.
|
||||
10. Local DoH + DoT endpoints serve LAN clients.
|
||||
11. Schema upgrade = install + restart (migration test proves it).
|
||||
12. All suites green in Gitea CI for both targets.
|
||||
@@ -692,5 +694,5 @@ The project publishes released binaries and container images from its own Gitea
|
||||
| G | SQLite vendored amalgamation + own thin wrapper |
|
||||
| H | Two DBs: `config.db` (precious) + `querylog.db` (expendable, self-contained, client IP as text) |
|
||||
| I | Frontend embedded in binary; dev flag serves from disk; static musl release builds |
|
||||
| J | Auto-migration for `config.db` at startup; `querylog.db` recreated on mismatch |
|
||||
| J | Auto-migration at startup for both databases; `querylog.db` recreated only when corrupt |
|
||||
| — | Safe-search per-group; Prometheus `/metrics` in scope; CI on self-hosted Gitea Actions |
|
||||
|
||||
@@ -70,15 +70,15 @@ The release artifacts come out of the same build graph, so the whole release bui
|
||||
```sh
|
||||
(cd admin && npm ci && npm run build) # required: dist refuses the placeholder
|
||||
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
|
||||
zig build dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \
|
||||
zig build dist -Dversion-string="$VERSION" \
|
||||
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/
|
||||
zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \
|
||||
zig build verify-dist -Dversion-string="$VERSION" \
|
||||
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # the release checks
|
||||
```
|
||||
|
||||
The version comes from `build.zig.zon` because `verify-dist` asserts the two agree; a tag sets both.
|
||||
|
||||
That is not a claim that your tarball will hash the same as a published one. Nothing in this project measures whether two builds of the same commit on two different machines land on the same bytes, so no document here describes the build as reproducible. The gate that would settle it is a recorded deferral — `specs/milestone-14.md` ruling 12 — and [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) explains what a matching or differing hash is worth in the meantime.
|
||||
On the toolchain versions pinned at the top of `.gitea/workflows/gates.yml`, your tarballs hash the same as the published ones of that version. [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) has the rebuild recipe and what to check when a hash differs.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
});
|
||||
fetch("/api/version")
|
||||
.then((r) => r.json())
|
||||
.then((v) => { el("version").textContent = v.version + " (" + v.git_commit + ")"; })
|
||||
.then((v) => { el("version").textContent = v.version; })
|
||||
.catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Generated
+522
-3
@@ -8,9 +8,16 @@
|
||||
"name": "nxdns-admin",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "2.1.10",
|
||||
"@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"
|
||||
@@ -1072,6 +1079,19 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@phosphor-icons/react": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
|
||||
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8",
|
||||
"react-dom": ">= 16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
|
||||
@@ -1619,6 +1639,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 +1731,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 +1751,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 +1761,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 +2107,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 +2520,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 +2614,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 +2672,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 +2841,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 +2990,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 +3412,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 +3726,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 +3777,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",
|
||||
|
||||
@@ -25,9 +25,16 @@
|
||||
"trailingComma": "all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "2.1.10",
|
||||
"@stylexjs/stylex": "0.19.0",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-router": "1.170.18",
|
||||
"@visx/axis": "4.0.0",
|
||||
"@visx/grid": "4.0.0",
|
||||
"@visx/group": "4.0.0",
|
||||
"@visx/scale": "4.0.0",
|
||||
"@visx/shape": "4.0.0",
|
||||
"@visx/tooltip": "4.0.0",
|
||||
"react": "19.2.8",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "19.2.8"
|
||||
|
||||
@@ -14,7 +14,7 @@ import { readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const BUDGET_BYTES = 800_000;
|
||||
const BUDGET_BYTES = 900_000;
|
||||
|
||||
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiError } from "@/lib/api";
|
||||
import { useAuth } from "@/auth/store";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
|
||||
const styles = stylex.create({
|
||||
/** Login renders outside AppShell, so it paints the page ground itself. */
|
||||
@@ -25,6 +26,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
probing: {
|
||||
marginTop: "1rem",
|
||||
@@ -64,7 +66,7 @@ function errorMessage(error: unknown, remaining: number | null): string {
|
||||
if (error.status === 401) return "Incorrect password.";
|
||||
if (error.status === 429) {
|
||||
return remaining !== null && remaining > 0
|
||||
? `Too many attempts. Try again in ${remaining}s.`
|
||||
? `Too many attempts. Try again in ${formatDuration(remaining)}.`
|
||||
: "Too many attempts. Try again shortly.";
|
||||
}
|
||||
if (error.status === 503) return "The server is starting or degraded. Try again shortly.";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
@@ -16,7 +16,7 @@ let responses: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
responses = {
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
"/api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
"/api/health": health(),
|
||||
};
|
||||
vi.stubGlobal(
|
||||
@@ -252,7 +252,7 @@ test("the back link restores the investigation the reader came from", async () =
|
||||
renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");
|
||||
|
||||
await screen.findByRole("heading", { name: "shop.example" });
|
||||
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
||||
expect(hrefSearch(within(screen.getByRole("main")).getByRole("link", { name: "Activity" }))).toEqual({
|
||||
mode: "history",
|
||||
domain: "shop",
|
||||
since: "1600000000",
|
||||
@@ -307,22 +307,23 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
|
||||
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.getByText(/no such query/)).toBeTruthy();
|
||||
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
||||
expect(hrefSearch(within(screen.getByRole("main")).getByRole("link", { name: "Activity" }))).toEqual({
|
||||
mode: "history",
|
||||
domain: "gone",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The related-actions region of a query detail. Scoped on purpose: the sidebar
|
||||
* carries a Pause of its own, and this is the one that answers "this query was
|
||||
* blocked and should not have been".
|
||||
*/
|
||||
/** The related-actions region of a query detail. */
|
||||
function related(): HTMLElement {
|
||||
return screen.getByRole("region", { name: "Related" });
|
||||
}
|
||||
|
||||
test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => {
|
||||
/**
|
||||
* Related is links only. Pause is a resolver-wide control and lives in the
|
||||
* sidebar alone, so a blocked query — the case that used to carry one here —
|
||||
* offers no button of any kind.
|
||||
*/
|
||||
test("Related carries its four links and no control, blocked query or not", async () => {
|
||||
responses["/api/queries/50"] = detail(50, {
|
||||
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
||||
route: { kind: "blocked", upstream: "" },
|
||||
@@ -330,25 +331,16 @@ test("a blocked query's Related offers Pause; an allowed one has nothing to paus
|
||||
renderDetail(50);
|
||||
|
||||
await screen.findByRole("heading", { name: "example.com" });
|
||||
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
|
||||
|
||||
cleanup();
|
||||
responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } });
|
||||
renderDetail(51);
|
||||
|
||||
await screen.findByRole("heading", { name: "example.com" });
|
||||
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the Pause action stays away while protection is unavailable", async () => {
|
||||
responses["/api/health"] = health({ protection: { state: "unavailable", until: null } });
|
||||
responses["/api/queries/52"] = detail(52, {
|
||||
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
||||
route: { kind: "blocked", upstream: "" },
|
||||
});
|
||||
renderDetail(52);
|
||||
|
||||
await screen.findByRole("heading", { name: "example.com" });
|
||||
await waitFor(() => expect(screen.getByText("Diagnostics around this query")).toBeTruthy());
|
||||
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
||||
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
|
||||
expect(
|
||||
within(related())
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.textContent),
|
||||
).toEqual([
|
||||
"Test this domain against current policy",
|
||||
"All activity for this domain",
|
||||
"All activity from this client",
|
||||
"Diagnostics around this query",
|
||||
]);
|
||||
expect(within(related()).queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
@@ -12,13 +13,21 @@ import type { ActivitySearch } from "./search";
|
||||
|
||||
const styles = stylex.create({
|
||||
back: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
backIcon: {
|
||||
display: "inline-flex",
|
||||
},
|
||||
/** About the height of the filled detail surface, so it does not jump in. */
|
||||
loading: {
|
||||
marginTop: "1rem",
|
||||
minHeight: "20rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
@@ -32,7 +41,10 @@ const styles = stylex.create({
|
||||
function BackLink({ origin }: { origin: ActivitySearch }) {
|
||||
return (
|
||||
<Link to="/activity" search={origin} {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← Activity
|
||||
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
|
||||
<ArrowLeft size={12} />
|
||||
</span>
|
||||
Activity
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -68,15 +80,7 @@ export default function ActivityDetailPage() {
|
||||
<ProvenanceDetail
|
||||
provenance={detail}
|
||||
persistedId={detail.id}
|
||||
relatedActions={
|
||||
<RelatedActions
|
||||
domain={domain}
|
||||
client={client}
|
||||
ts={time}
|
||||
origin={origin}
|
||||
blocked={detail.policy.action === "block"}
|
||||
/>
|
||||
}
|
||||
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
/**
|
||||
* The filter toolbar on its own: the controls that decide what reaches the URL,
|
||||
* driven through a harness that plays the part the page plays.
|
||||
*
|
||||
* The timezone is fixed because the only invalid bound reachable in jsdom is a
|
||||
* wall-clock time inside a spring-forward gap: jsdom applies the
|
||||
* `datetime-local` value sanitization algorithm, so text that is merely
|
||||
* incomplete never reaches the component at all — the input hands it back as
|
||||
* the empty string, which is a bound the operator cleared.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { afterAll, beforeEach, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { Client } from "@/lib/types";
|
||||
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||
import { unixToDatetimeLocal } from "./datetime";
|
||||
|
||||
vi.stubEnv("TZ", "Europe/Paris");
|
||||
afterAll(() => vi.unstubAllEnvs());
|
||||
|
||||
function client(ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
id: 1,
|
||||
ip,
|
||||
name,
|
||||
learned_name: learnedName,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: name !== "",
|
||||
first_seen: 1_700_000_000,
|
||||
last_seen: 1_700_000_100,
|
||||
};
|
||||
}
|
||||
|
||||
/** Two named clients and one bare address: every case the picker has to show. */
|
||||
const CLIENTS: Client[] = [
|
||||
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||
client("192.0.2.11", "", "laptop.lan"),
|
||||
client("192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
/** The picker reads the clients query the query tables already load. */
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/clients") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ clients: CLIENTS }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/** A household bigger than the chip row and, at 40, bigger than the 32 cap. */
|
||||
function manyClients(count: number): Client[] {
|
||||
return Array.from({ length: count }, (_, index) => client(`198.51.100.${index + 1}`, `Device ${index + 1}`, ""));
|
||||
}
|
||||
|
||||
/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
|
||||
const GAP_WALL_TIME = "2026-03-29T02:30:00";
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's half of the contract: the applied state is held outside the form.
|
||||
*
|
||||
* `deferred` holds the patches back instead of applying them, so a test can
|
||||
* land them in an order the network and the router can genuinely produce —
|
||||
* an early debounce arriving after a later one, over a draft that has moved on.
|
||||
*/
|
||||
function renderFilters(
|
||||
initial: AppliedFilters = NO_FILTERS,
|
||||
deferred = false,
|
||||
primeClients = true,
|
||||
clients: Client[] = CLIENTS,
|
||||
) {
|
||||
const patches: Array<Partial<AppliedFilters>> = [];
|
||||
const state: { current: AppliedFilters } = { current: initial };
|
||||
const setter: { current: ((next: AppliedFilters) => void) | null } = { current: null };
|
||||
|
||||
function Harness() {
|
||||
const [applied, setApplied] = useState(initial);
|
||||
state.current = applied;
|
||||
setter.current = setApplied;
|
||||
const onApply = useCallback((patch: Partial<AppliedFilters>) => {
|
||||
patches.push(patch);
|
||||
if (!deferred) setApplied((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
const onClear = useCallback(() => {
|
||||
patches.push({});
|
||||
if (!deferred) setApplied(NO_FILTERS);
|
||||
}, []);
|
||||
return <ActivityFilters applied={applied} onApply={onApply} onClear={onClear} />;
|
||||
}
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
// Seeded rather than fetched, so the picker has its options on the first
|
||||
// render and a test of the toolbar is not also a test of a request landing.
|
||||
// The one test that is about that arrival opts out and waits for the stub.
|
||||
if (primeClients) queryClient.setQueryData(queryKeys.clients, clients);
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
/** The URL moving under the form: a commit landing, or the back button. */
|
||||
function land(next: AppliedFilters) {
|
||||
act(() => setter.current!(next));
|
||||
}
|
||||
return { patches, state, land, queryClient };
|
||||
}
|
||||
|
||||
/**
|
||||
* A press, as react-aria hears one. `usePress` works in pointer events, or in
|
||||
* mouse events where jsdom has no `PointerEvent`; a bare `click` is neither, and
|
||||
* RAC's own controls do not respond to it.
|
||||
*/
|
||||
function press(element: HTMLElement) {
|
||||
fireEvent.mouseDown(element);
|
||||
fireEvent.mouseUp(element);
|
||||
fireEvent.click(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's trigger: the first button of the client group, whatever it reads
|
||||
* as. Its label is the selection, so it cannot be looked up by a fixed name.
|
||||
*/
|
||||
function clientTrigger(): HTMLElement {
|
||||
return within(screen.getByRole("group", { name: "Clients" })).getAllByRole("button")[0] as HTMLElement;
|
||||
}
|
||||
|
||||
/** The removable chips, in the order they are shown, by what each one reads as. */
|
||||
function clientChips(): string[] {
|
||||
return within(screen.getByRole("group", { name: "Clients" }))
|
||||
.getAllByRole("button")
|
||||
.slice(1)
|
||||
.map((chip) => chip.textContent ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the client menu, if it is not open already.
|
||||
*
|
||||
* The open state is read from the trigger rather than assumed: an open RAC
|
||||
* popover hides the rest of the page from the accessibility tree — the trigger
|
||||
* included — so a blind second click would either fail to find it or shut the
|
||||
* menu it was meant to open.
|
||||
*/
|
||||
function openClientMenu() {
|
||||
if (clientTrigger().getAttribute("aria-expanded") === "true") return;
|
||||
fireEvent.click(clientTrigger());
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks one client and shuts the menu again.
|
||||
*
|
||||
* A multiple-selection menu stays open on a pick, which is the point of it — but
|
||||
* an open RAC popover hides the rest of the page from the accessibility tree, so
|
||||
* a test that wants to read the chips has to close it first, exactly as a reader
|
||||
* would before looking at them.
|
||||
*/
|
||||
function pickClient(name: string) {
|
||||
openClientMenu();
|
||||
press(screen.getByRole("menuitemcheckbox", { name }));
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape", code: "Escape" });
|
||||
}
|
||||
|
||||
function openTimeMenu() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||
}
|
||||
|
||||
test("the segments commit to the applied state on the click, with no button in between", () => {
|
||||
const { patches, state } = renderFilters();
|
||||
expect(screen.getAllByRole("radio").map((radio) => radio.closest("label")?.textContent)).toEqual([
|
||||
"Any",
|
||||
"Blocked",
|
||||
"Allowed",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
expect(patches).toEqual([{ blocked: true }]);
|
||||
expect(state.current.blocked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Allowed" }));
|
||||
expect(state.current.blocked).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Any" }));
|
||||
expect(state.current.blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a time preset writes an absolute second, and the trigger names the preset", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { patches } = renderFilters();
|
||||
|
||||
openTimeMenu();
|
||||
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
|
||||
"Any time",
|
||||
"Past hour",
|
||||
"Past 24 hours",
|
||||
"Past 7 days",
|
||||
"Custom…",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));
|
||||
|
||||
// Both bounds, concrete: an open upper bound would keep taking in queries
|
||||
// logged after the reader stopped looking, so the same link tomorrow would
|
||||
// name a different day.
|
||||
expect(patches).toEqual([{ since: now / 1000 - 86_400, until: now / 1000 }]);
|
||||
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
|
||||
|
||||
// A clock that has moved on does not move the label: the URL still holds the
|
||||
// second the click resolved to.
|
||||
vi.spyOn(Date, "now").mockReturnValue(now + 60_000);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("bounds nobody picked here read as Custom, and Any time clears both", () => {
|
||||
const { patches } = renderFilters({ ...NO_FILTERS, since: 1_700_000_000, until: 1_700_000_600 });
|
||||
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
|
||||
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Any time" }));
|
||||
|
||||
expect(patches).toEqual([{ since: undefined, until: undefined }]);
|
||||
expect(screen.getByRole("button", { name: "Time: Any time" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the custom range applies only through Set range, and Enter is Set range", () => {
|
||||
const { patches } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
fireEvent.change(since, { target: { value: "2026-03-29T04:30:00" } });
|
||||
// Typing a bound is not applying it: the other half may still be half-typed.
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
const applied = { since: Math.floor(new Date("2026-03-29T04:30:00").getTime() / 1000), until: undefined };
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(patches).toEqual([applied]);
|
||||
|
||||
// Enter inside a bound is that bound's action, not the toolbar's submit: the
|
||||
// outer form flushes the text filters and would apply neither half of this.
|
||||
fireEvent.change(since, { target: { value: "2026-03-29T05:30:00" } });
|
||||
fireEvent.keyDown(since, { key: "Enter" });
|
||||
expect(patches).toHaveLength(2);
|
||||
expect(patches[1]).toEqual({
|
||||
since: Math.floor(new Date("2026-03-29T05:30:00").getTime() / 1000),
|
||||
until: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("a rejected bound marks its own input and describes it", () => {
|
||||
const { patches } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
|
||||
expect(since.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(since)).toContain("daylight saving");
|
||||
// Refused means refused, and only the offending bound carries the mark.
|
||||
expect(patches).toEqual([]);
|
||||
expect(until.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(until.getAttribute("aria-describedby")).toBeNull();
|
||||
});
|
||||
|
||||
test("an upper bound below the lower one is refused, against the bound that is wrong", () => {
|
||||
const { patches } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||
fireEvent.change(since, { target: { value: "2026-05-02T10:00:00" } });
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
|
||||
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(until)).toContain("selects nothing");
|
||||
expect(since.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
// The window is half-open, so two equal bounds are as empty as an inverted
|
||||
// pair and are refused the same way.
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T10:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T11:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(until.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(patches).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Clear keeps its place while there is nothing to clear, and stays out of the tab order", () => {
|
||||
const { state } = renderFilters();
|
||||
const clear = screen.getByText("Clear");
|
||||
expect(clear.getAttribute("tabindex")).toBe("-1");
|
||||
expect(clear.getAttribute("aria-hidden")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
|
||||
expect(clear.getAttribute("tabindex")).toBeNull();
|
||||
expect(clear.getAttribute("aria-hidden")).toBeNull();
|
||||
|
||||
fireEvent.click(clear);
|
||||
expect(state.current).toEqual(NO_FILTERS);
|
||||
expect(clear.getAttribute("tabindex")).toBe("-1");
|
||||
});
|
||||
|
||||
test("Clear empties the text drafts along with the applied filters", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { state } = renderFilters();
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
fireEvent.change(domain, { target: { value: "ads" } });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(state.current.domain).toBe("ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
|
||||
expect(domain.value).toBe("");
|
||||
expect(state.current).toEqual(NO_FILTERS);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("the domain field is search-shaped, unspellchecked, and labelled without a visible label", () => {
|
||||
renderFilters();
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
expect(domain.type).toBe("search");
|
||||
expect(domain.getAttribute("spellcheck")).toBe("false");
|
||||
expect(domain.getAttribute("autocomplete")).toBe("off");
|
||||
expect(domain.getAttribute("placeholder")).toBe("Filter domains…");
|
||||
// The magnifier is decoration over the field, never a second thing to read.
|
||||
expect(domain.parentElement?.querySelector("[aria-hidden='true'] svg")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a bound change does not reset the domain draft that is still being typed", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { state } = renderFilters();
|
||||
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
fireEvent.change(domain, { target: { value: "ads" } });
|
||||
|
||||
// A preset commits at once, while the typed word is still waiting out its
|
||||
// debounce. The URL moves, but not the part of it this field is derived from.
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
|
||||
expect(state.current.since).toBe(now / 1000 - 3600);
|
||||
expect(domain.value).toBe("ads");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("a debounced commit landing late does not roll the field back over newer typing", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { patches, land } = renderFilters(NO_FILTERS, true);
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
|
||||
// Two commits in flight, and the reader is a keystroke ahead of both.
|
||||
fireEvent.change(domain, { target: { value: "ad" } });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
fireEvent.change(domain, { target: { value: "ads" } });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(patches).toEqual([{ domain: "ad" }, { domain: "ads" }]);
|
||||
fireEvent.change(domain, { target: { value: "adsx" } });
|
||||
|
||||
// The older one lands first. It is this toolbar's own echo, two keystrokes
|
||||
// stale, and seeding the field from it would delete what was typed since.
|
||||
land({ ...NO_FILTERS, domain: "ad" });
|
||||
expect(domain.value).toBe("adsx");
|
||||
|
||||
land({ ...NO_FILTERS, domain: "ads" });
|
||||
expect(domain.value).toBe("adsx");
|
||||
|
||||
// An address that was never sent from here is someone else's — a pasted
|
||||
// link, or the back button — and that one does move the field.
|
||||
land({ ...NO_FILTERS, domain: "elsewhere" });
|
||||
expect(domain.value).toBe("elsewhere");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a window moved from outside re-opens the custom row over the bounds it arrived with", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { land } = renderFilters();
|
||||
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
// A preset supersedes the custom row, so it is shut and the label is the preset.
|
||||
expect(screen.queryByLabelText("Since")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
|
||||
|
||||
// The back button, or a pasted link: a range this form did not choose.
|
||||
const since = 1_700_000_000;
|
||||
land({ ...NO_FILTERS, since, until: since + 600 });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
|
||||
// The label says Custom, so the fields it names have to be on screen holding
|
||||
// that range — a Custom window with nothing to read is the label lying.
|
||||
// jsdom's value sanitizer spells the milliseconds out, so the seeded text is
|
||||
// the prefix rather than the whole of what the input holds.
|
||||
expect((screen.getByLabelText("Since") as HTMLInputElement).value).toContain(unixToDatetimeLocal(since));
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).value).toContain(unixToDatetimeLocal(since + 600));
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("a window moved from outside clears an error left over from the old one", () => {
|
||||
const { land } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: "2026-05-02T10:00:00" } });
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
const since = 1_700_000_000;
|
||||
land({ ...NO_FILTERS, since, until: since + 600 });
|
||||
|
||||
// The bounds the message was about are gone, so the message is too.
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).getAttribute("aria-invalid")).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("the picker offers every client and names the filter it is not yet applying", () => {
|
||||
renderFilters();
|
||||
expect(clientTrigger().textContent).toContain("Clients");
|
||||
expect(clientChips()).toEqual([]);
|
||||
|
||||
openClientMenu();
|
||||
// The name is the way in; the address is still the filter.
|
||||
expect(screen.getAllByRole("menuitemcheckbox").map((item) => item.textContent)).toEqual([
|
||||
"192.0.2.12",
|
||||
"Kitchen Pi — 192.0.2.10",
|
||||
"laptop.lan — 192.0.2.11",
|
||||
]);
|
||||
});
|
||||
|
||||
test("picking clients commits them to the url at once, as one comma-separated value", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { patches, state } = renderFilters();
|
||||
|
||||
pickClient("Kitchen Pi — 192.0.2.10");
|
||||
// No debounce: a pick is a decision the reader has finished making, and
|
||||
// nothing about it can be half-typed.
|
||||
expect(patches).toEqual([{ client: "192.0.2.10" }]);
|
||||
expect(state.current.client).toBe("192.0.2.10");
|
||||
|
||||
pickClient("laptop.lan — 192.0.2.11");
|
||||
expect(patches).toEqual([{ client: "192.0.2.10" }, { client: "192.0.2.10,192.0.2.11" }]);
|
||||
expect(state.current.client).toBe("192.0.2.10,192.0.2.11");
|
||||
|
||||
// Nothing lands later either: there is no pending commit behind these.
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(patches).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("the trigger names a single client and counts several", () => {
|
||||
const { state } = renderFilters();
|
||||
|
||||
pickClient("Kitchen Pi — 192.0.2.10");
|
||||
expect(clientTrigger().textContent).toContain("Kitchen Pi — 192.0.2.10");
|
||||
|
||||
pickClient("laptop.lan — 192.0.2.11");
|
||||
expect(clientTrigger().textContent).toContain("2 clients");
|
||||
expect(state.current.client).toBe("192.0.2.10,192.0.2.11");
|
||||
});
|
||||
|
||||
test("a picked client is a chip, and the chip takes it back off", () => {
|
||||
const { state } = renderFilters();
|
||||
|
||||
pickClient("Kitchen Pi — 192.0.2.10");
|
||||
pickClient("laptop.lan — 192.0.2.11");
|
||||
// A chip is one client beside a row of others, so it wears the short form: the
|
||||
// address is the half that does not fit, and the menu still spells it out.
|
||||
expect(clientChips()).toEqual(["Kitchen Pi", "laptop.lan"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove client Kitchen Pi — 192.0.2.10" }));
|
||||
|
||||
expect(state.current.client).toBe("192.0.2.11");
|
||||
expect(clientChips()).toEqual(["laptop.lan"]);
|
||||
});
|
||||
|
||||
test("an address no client claims still shows as a chip and can still be removed", () => {
|
||||
// The log names devices the config has never heard of, and a link filtered on
|
||||
// one has to stay readable and clearable even though the menu cannot offer it.
|
||||
const { state } = renderFilters({ ...NO_FILTERS, client: "203.0.113.9,192.0.2.10" });
|
||||
|
||||
expect(clientChips()).toEqual(["203.0.113.9", "Kitchen Pi"]);
|
||||
expect(clientTrigger().textContent).toContain("2 clients");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove client 203.0.113.9" }));
|
||||
expect(state.current.client).toBe("192.0.2.10");
|
||||
});
|
||||
|
||||
test("the menu ticks the clients the url names, not the ones last picked here", () => {
|
||||
const { land } = renderFilters();
|
||||
pickClient("Kitchen Pi — 192.0.2.10");
|
||||
|
||||
// The URL moves to a different client from somewhere else.
|
||||
land({ ...NO_FILTERS, client: "192.0.2.11" });
|
||||
expect(clientChips()).toEqual(["laptop.lan"]);
|
||||
|
||||
openClientMenu();
|
||||
const ticked = screen
|
||||
.getAllByRole("menuitemcheckbox")
|
||||
.filter((item) => item.getAttribute("aria-checked") === "true");
|
||||
expect(ticked.map((item) => item.textContent)).toEqual(["laptop.lan — 192.0.2.11"]);
|
||||
});
|
||||
|
||||
test("a rename moves the label and leaves the filter on the address", async () => {
|
||||
const { state, queryClient } = renderFilters();
|
||||
pickClient("Kitchen Pi — 192.0.2.10");
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.clients,
|
||||
CLIENTS.map((entry) => (entry.ip === "192.0.2.10" ? { ...entry, name: "Hallway Pi" } : entry)),
|
||||
);
|
||||
});
|
||||
|
||||
// Nothing here holds a label, so nothing here can commit one: the chip is a
|
||||
// rendering of the address, re-rendered.
|
||||
await waitFor(() => expect(clientChips()).toEqual(["Hallway Pi"]));
|
||||
expect(state.current.client).toBe("192.0.2.10");
|
||||
});
|
||||
|
||||
test("Clear empties the picker along with everything else", () => {
|
||||
const { state } = renderFilters();
|
||||
pickClient("Kitchen Pi — 192.0.2.10");
|
||||
expect(clientChips()).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
|
||||
expect(clientChips()).toEqual([]);
|
||||
expect(state.current).toEqual(NO_FILTERS);
|
||||
});
|
||||
|
||||
test("with no clients loaded the trigger is disabled rather than opening on nothing", () => {
|
||||
renderFilters({ ...NO_FILTERS, client: "192.0.2.10" }, false, false);
|
||||
|
||||
expect(clientTrigger().hasAttribute("disabled")).toBe(true);
|
||||
// The filter the URL carries is still on screen and still removable, because
|
||||
// none of that waits on the convenience that names it.
|
||||
expect(clientChips()).toEqual(["192.0.2.10"]);
|
||||
});
|
||||
|
||||
test("past three chips the rest are a count, and the count removes nothing", () => {
|
||||
const clients = manyClients(12);
|
||||
renderFilters({ ...NO_FILTERS, client: clients.map((entry) => entry.ip).join(",") }, false, true, clients);
|
||||
|
||||
// Three, and then how many more. A dozen chips are not more visible than
|
||||
// three: they wrap the toolbar into a block and push the table off the screen.
|
||||
expect(clientChips()).toEqual(["Device 1", "Device 2", "Device 3"]);
|
||||
expect(screen.getByText("+9 more")).toBeTruthy();
|
||||
expect(clientTrigger().textContent).toContain("12 clients");
|
||||
|
||||
// The summary is not a button, so there is no fourth thing to remove and no
|
||||
// pointer target that does nothing.
|
||||
expect(within(screen.getByRole("group", { name: "Clients" })).getAllByRole("button")).toHaveLength(4);
|
||||
expect(screen.queryByRole("button", { name: /Remove client Device 4/ })).toBeNull();
|
||||
});
|
||||
|
||||
test("every known client at once commits every one of their addresses", () => {
|
||||
const { patches, state } = renderFilters();
|
||||
|
||||
pickClient("Kitchen Pi — 192.0.2.10");
|
||||
pickClient("laptop.lan — 192.0.2.11");
|
||||
expect(state.current.client).toBe("192.0.2.10,192.0.2.11");
|
||||
|
||||
// The whole household is still written out. What is picked is what is
|
||||
// committed, so the ticks, the chips and the URL all say what the reader just
|
||||
// did — and pruned clients keep history rows, so this is not "no filter".
|
||||
pickClient("192.0.2.12");
|
||||
expect(patches.at(-1)).toEqual({ client: "192.0.2.10,192.0.2.11,192.0.2.12" });
|
||||
expect(state.current.client).toBe("192.0.2.10,192.0.2.11,192.0.2.12");
|
||||
expect(clientChips()).toEqual(["Kitchen Pi", "laptop.lan", "192.0.2.12"]);
|
||||
});
|
||||
|
||||
test("the only client on the network is still a client you can pick", () => {
|
||||
// The household the owner hit: one known client, so picking it is picking all
|
||||
// of them. A picker that answered that click by clearing itself would read as
|
||||
// a control that did nothing at all.
|
||||
const { state } = renderFilters(NO_FILTERS, false, true, [CLIENTS[0] as Client]);
|
||||
|
||||
pickClient("Kitchen Pi — 192.0.2.10");
|
||||
|
||||
expect(state.current.client).toBe("192.0.2.10");
|
||||
expect(clientChips()).toEqual(["Kitchen Pi"]);
|
||||
expect(clientTrigger().textContent).toContain("Kitchen Pi — 192.0.2.10");
|
||||
|
||||
openClientMenu();
|
||||
expect(screen.getByRole("menuitemcheckbox", { name: "Kitchen Pi — 192.0.2.10" }).getAttribute("aria-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("the menu stops at the cap the server enforces, and says so", () => {
|
||||
const clients = manyClients(40);
|
||||
const picked = clients.slice(0, 32).map((entry) => entry.ip);
|
||||
const { patches } = renderFilters({ ...NO_FILTERS, client: picked.join(",") }, false, true, clients);
|
||||
|
||||
openClientMenu();
|
||||
expect(screen.getByText("At most 32 clients at a time.")).toBeTruthy();
|
||||
const item = screen.getByRole("menuitemcheckbox", { name: "Device 33 — 198.51.100.33" });
|
||||
expect(item.getAttribute("aria-disabled")).toBe("true");
|
||||
|
||||
// A menu that took a 33rd pick and dropped it would look like it had worked.
|
||||
press(item);
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
// What is already picked can still be unpicked, or the reader would be stuck.
|
||||
const chosen = screen.getByRole("menuitemcheckbox", { name: "Device 1 — 198.51.100.1" });
|
||||
expect(chosen.getAttribute("aria-disabled")).toBeNull();
|
||||
});
|
||||
|
||||
test("the cap note appears when the cap binds, however few clients are loaded", () => {
|
||||
const clients = manyClients(5);
|
||||
// Two known and thirty unknown: the cap is full while the menu still has three
|
||||
// rows it will not let anyone pick, and a disabled row with nothing to explain
|
||||
// it is the one state this must not reach.
|
||||
const unknown = Array.from({ length: 30 }, (_, index) => `203.0.113.${index + 1}`);
|
||||
renderFilters(
|
||||
{ ...NO_FILTERS, client: [clients[0]!.ip, clients[1]!.ip, ...unknown].join(",") },
|
||||
false,
|
||||
true,
|
||||
clients,
|
||||
);
|
||||
|
||||
openClientMenu();
|
||||
expect(screen.getByText("At most 32 clients at a time.")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("menuitemcheckbox", { name: "Device 3 — 198.51.100.3" }).getAttribute("aria-disabled"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
test("with room left the cap note stays out of the menu", () => {
|
||||
renderFilters();
|
||||
openClientMenu();
|
||||
|
||||
// Three clients and nothing picked: a ceiling nobody can reach is not news.
|
||||
expect(screen.queryByText("At most 32 clients at a time.")).toBeNull();
|
||||
});
|
||||
|
||||
test("an address no client claims takes a slot in the cap like any other", () => {
|
||||
const clients = manyClients(40);
|
||||
// Thirty-one known and one the config has never heard of. The cap is on what
|
||||
// the request may name, not on what this menu happens to be able to show.
|
||||
const picked = [...clients.slice(0, 31).map((entry) => entry.ip), "203.0.113.9"];
|
||||
renderFilters({ ...NO_FILTERS, client: picked.join(",") }, false, true, clients);
|
||||
|
||||
openClientMenu();
|
||||
const item = screen.getByRole("menuitemcheckbox", { name: "Device 33 — 198.51.100.33" });
|
||||
expect(item.getAttribute("aria-disabled")).toBe("true");
|
||||
});
|
||||
|
||||
test("select-all is held to the cap, with the addresses already filtered keeping their slots", () => {
|
||||
const clients = manyClients(40);
|
||||
const unknown = ["203.0.113.1", "203.0.113.2", "203.0.113.3", "203.0.113.4", "203.0.113.5"];
|
||||
const { state } = renderFilters({ ...NO_FILTERS, client: unknown.join(",") }, false, true, clients);
|
||||
|
||||
openClientMenu();
|
||||
// Ctrl+A reaches the selection without pressing an item, so the disabled rows
|
||||
// never see it and the cap has to hold here too.
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "a", code: "KeyA", ctrlKey: true });
|
||||
fireEvent.keyUp(screen.getByRole("menu"), { key: "a", code: "KeyA", ctrlKey: true });
|
||||
|
||||
const applied = state.current.client?.split(",") ?? [];
|
||||
expect(applied).toHaveLength(32);
|
||||
// What was already filtered keeps its slots and the new picks take what is
|
||||
// left, rather than the list being cut wherever it happened to run out.
|
||||
expect(applied.slice(0, 5)).toEqual(unknown);
|
||||
// The rest are known clients, each once. Which 27 is the menu's own order and
|
||||
// not something this test should restate.
|
||||
const rest = applied.slice(5);
|
||||
const addresses = new Set(clients.map((entry) => entry.ip));
|
||||
expect(rest.every((ip) => addresses.has(ip))).toBe(true);
|
||||
expect(new Set(rest).size).toBe(27);
|
||||
});
|
||||
|
||||
test("removing a chip hands the focus on rather than dropping it", () => {
|
||||
const clients = manyClients(5);
|
||||
const { state } = renderFilters(
|
||||
{
|
||||
...NO_FILTERS,
|
||||
client: clients
|
||||
.slice(0, 4)
|
||||
.map((entry) => entry.ip)
|
||||
.join(","),
|
||||
},
|
||||
false,
|
||||
true,
|
||||
clients,
|
||||
);
|
||||
|
||||
// The chip that takes the removed one's place, so a reader clearing three
|
||||
// clients from the keyboard does not tab back in from the top each time.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 2 — 198.51.100.2" }));
|
||||
expect(document.activeElement?.getAttribute("aria-label")).toBe("Remove client Device 3 — 198.51.100.3");
|
||||
|
||||
// Nothing after it, so the one before it.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 4 — 198.51.100.4" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 3 — 198.51.100.3" }));
|
||||
expect(document.activeElement?.getAttribute("aria-label")).toBe("Remove client Device 1 — 198.51.100.1");
|
||||
|
||||
// No chips left, so the control the row belongs to.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 1 — 198.51.100.1" }));
|
||||
expect(document.activeElement).toBe(clientTrigger());
|
||||
expect(state.current.client).toBeUndefined();
|
||||
});
|
||||
@@ -1,73 +1,195 @@
|
||||
/**
|
||||
* The filter row over the Activity table.
|
||||
* The filter toolbar over the Activity history table.
|
||||
*
|
||||
* The applied state is the URL, never this form: what the reader sees is what
|
||||
* the link they can paste to a housemate will show. So this holds a draft only,
|
||||
* and the page remounts it whenever the applied search changes — a back button
|
||||
* or a pasted URL has to move the form with it, and a form that seeded itself
|
||||
* once would keep showing the previous investigation's filters.
|
||||
* the link they can paste to a housemate will show. So this holds a draft, and
|
||||
* every control writes through to the URL — the segments, the client picker and
|
||||
* the time presets at once, the domain field after a pause so a five-letter word
|
||||
* is one navigation rather than five.
|
||||
*
|
||||
* In live mode the row stays visible and disabled rather than disappearing: the
|
||||
* filters are retained in the URL and apply again the moment history comes
|
||||
* back, and hiding them would read as having lost them. The stream itself is
|
||||
* unfiltered — the server sends every query — so a row that looked usable here
|
||||
* A time preset writes both bounds as absolute seconds, resolved once at the
|
||||
* click. Neither half may be left open: a bookmark has to describe the same
|
||||
* investigation tomorrow, and a window that slid overnight — or one that stayed
|
||||
* open at the top and swallowed everything logged since — would answer a
|
||||
* different question under the same link.
|
||||
*
|
||||
* A URL can move under this form at any time, and the draft only follows the
|
||||
* part of it that actually moved. A field is re-seeded when its own URL value
|
||||
* changed and the new value is not one this toolbar just wrote: the debounce
|
||||
* means the URL is always a little behind the keyboard, and a landing commit
|
||||
* must not roll the input back over the letters typed since.
|
||||
*
|
||||
* The custom range is the one control that does not live-apply. Two half-typed
|
||||
* timestamps are a normal intermediate state of typing one of them, and a lower
|
||||
* bound at or above an upper bound selects nothing at all, so the pair is
|
||||
* validated and applied together or not at all.
|
||||
*
|
||||
* Live mode does not render this at all — the page mounts it inside the History
|
||||
* panel. The stream is unfiltered, and a row of controls that looked usable
|
||||
* would promise filtering that is not happening.
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react/dist/icons/MagnifyingGlass";
|
||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import Select from "@/ui/Select";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import ClientFilter, { joinClients, parseClients, useClientOptions } from "./ClientFilter";
|
||||
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
{ value: "allowed", label: "Allowed only" },
|
||||
];
|
||||
/** Long enough that a typed word is one navigation, short enough to feel live. */
|
||||
const DEBOUNCE_MS = 350;
|
||||
|
||||
const RESULTS = [
|
||||
{ value: "any", label: "Any" },
|
||||
{ value: "blocked", label: "Blocked" },
|
||||
{ value: "allowed", label: "Allowed" },
|
||||
] as const;
|
||||
|
||||
const PRESETS = [
|
||||
{ label: "Any time", seconds: null },
|
||||
{ label: "Past hour", seconds: 3600 },
|
||||
{ label: "Past 24 hours", seconds: 86_400 },
|
||||
{ label: "Past 7 days", seconds: 604_800 },
|
||||
] as const;
|
||||
|
||||
const CUSTOM_ITEM = "Custom…";
|
||||
|
||||
const styles = stylex.create({
|
||||
/** One column on a phone, two from `sm`, five from `lg`. */
|
||||
grid: {
|
||||
toolbar: {
|
||||
marginTop: "1rem",
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
/** The domain field is the one that grows; everything else keeps its size. */
|
||||
searchWrap: {
|
||||
position: "relative",
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
flexBasis: "14rem",
|
||||
display: "flex",
|
||||
},
|
||||
label: {
|
||||
display: "block",
|
||||
searchIcon: {
|
||||
display: "inline-flex",
|
||||
position: "absolute",
|
||||
insetInlineStart: "0.5rem",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: colors.textMuted,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
/** Every control in the row is a pointer target before it is anything else. */
|
||||
field: {
|
||||
minHeight: metrics.hitTarget,
|
||||
},
|
||||
searchInput: {
|
||||
width: "100%",
|
||||
paddingInlineStart: "1.875rem",
|
||||
},
|
||||
/** A button is text-sized by default; this is the hit area around the text. */
|
||||
hitTarget: {
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
resultGroup: {
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
/** The segment styling of the Overview period picker, item for item. */
|
||||
segment: {
|
||||
cursor: "pointer",
|
||||
borderStyle: "none",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.625rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
// The weight is on the base, not on the selected state: a bolder label is a
|
||||
// wider label, and the row would shift under the pointer on every pick.
|
||||
fontWeight: 500,
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
||||
segmentFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
||||
segmentSelected: {
|
||||
backgroundColor: {
|
||||
default: "oklch(92% 0.004 286.32)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||
},
|
||||
color: colors.text,
|
||||
},
|
||||
segmentIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
popover: {
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menu: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
menuItem: {
|
||||
cursor: "pointer",
|
||||
paddingInline: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
whiteSpace: "nowrap",
|
||||
minHeight: metrics.hitTarget,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
/** Inset because an item flush against the popover edge clips an outset ring. */
|
||||
menuItemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
/**
|
||||
* Clear keeps its box when there is nothing to clear. It appears the moment a
|
||||
* filter is set, and a control that appeared by widening the row would move
|
||||
* every other control out from under the pointer that was reaching for it.
|
||||
*/
|
||||
clearHidden: {
|
||||
visibility: "hidden",
|
||||
},
|
||||
customRow: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
customField: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
input: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
// A disabled native input keeps its value legible but reads as inert,
|
||||
// matching what RAC does to the Select trigger beside it.
|
||||
cursor: { default: null, ":disabled": "not-allowed" },
|
||||
opacity: { default: null, ":disabled": 0.55 },
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.5rem",
|
||||
gridColumn: {
|
||||
default: null,
|
||||
"@media (min-width: 640px)": "span 2 / span 2",
|
||||
"@media (min-width: 1024px)": "span 5 / span 5",
|
||||
},
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
error: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
@@ -86,6 +208,16 @@ export const NO_FILTERS: AppliedFilters = {
|
||||
until: undefined,
|
||||
};
|
||||
|
||||
/** The half-open window the API reads: `ts >= since` and `ts < until`. */
|
||||
interface Bounds {
|
||||
since: number | undefined;
|
||||
until: number | undefined;
|
||||
}
|
||||
|
||||
function sameBounds(a: Bounds, b: Bounds): boolean {
|
||||
return a.since === b.since && a.until === b.until;
|
||||
}
|
||||
|
||||
function blockedOption(blocked: boolean | undefined): string {
|
||||
if (blocked === undefined) return "any";
|
||||
return blocked ? "blocked" : "allowed";
|
||||
@@ -96,135 +228,374 @@ function optionBlocked(value: string): boolean | undefined {
|
||||
return value === "allowed" ? false : undefined;
|
||||
}
|
||||
|
||||
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
|
||||
return reason === "unparseable"
|
||||
/** A filter value the URL can carry: trimmed, and empty means absent. */
|
||||
function textFilter(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/** The message plus the bound it belongs to, so that input can point at it. */
|
||||
interface BoundError {
|
||||
field: "since" | "until";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** The toolbar is rendered once per page, so the message can hold a fixed id. */
|
||||
const ERROR_ID = "activity-filter-error";
|
||||
|
||||
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
|
||||
const label = field === "since" ? "Since" : "Until";
|
||||
return {
|
||||
field,
|
||||
message:
|
||||
reason === "unparseable"
|
||||
? `${label} is not a complete date and time.`
|
||||
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
|
||||
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The preset a label is claimed for, kept only while the URL still holds it. */
|
||||
interface ChosenPreset extends Bounds {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this toolbar knows about the URL, and what it is still waiting to see
|
||||
* come back from it.
|
||||
*
|
||||
* `domain` and `bounds` are the last values looked at, so a change can be told
|
||||
* apart field by field. `pendingDomain` and `pendingBounds` are every commit
|
||||
* made here that the URL has not echoed yet. Both are queues rather than single
|
||||
* slots: two keystrokes either side of the debounce put two domain commits in
|
||||
* flight, and two menu picks in quick succession do the same to the range. In
|
||||
* both cases the older echo landing second must not be mistaken for someone
|
||||
* else's edit — that is what would clear the preset out from under the pick
|
||||
* that is actually current.
|
||||
*
|
||||
* Only the domain needs any of this. Every other control commits on the action
|
||||
* itself, so the URL is never behind what the reader is doing with it.
|
||||
*/
|
||||
interface Sync {
|
||||
domain: string;
|
||||
bounds: Bounds;
|
||||
pendingDomain: string[];
|
||||
pendingBounds: Bounds[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
applied: AppliedFilters;
|
||||
isDisabled: boolean;
|
||||
onApply: (filters: AppliedFilters) => void;
|
||||
/** Merges a patch into the applied search. `replace` keeps typing out of history. */
|
||||
onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
|
||||
const [domain, setDomain] = useState(applied.domain ?? "");
|
||||
const [client, setClient] = useState(applied.client ?? "");
|
||||
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
||||
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
||||
const urlDomain = applied.domain ?? "";
|
||||
const urlBounds: Bounds = { since: applied.since, until: applied.until };
|
||||
|
||||
const clientOptions = useClientOptions();
|
||||
// The picked clients are read straight off the URL: there is no draft to hold,
|
||||
// because there is no state here the reader can leave half-finished.
|
||||
const clients = parseClients(applied.client);
|
||||
const [domain, setDomain] = useState(urlDomain);
|
||||
const [preset, setPreset] = useState<ChosenPreset | null>(null);
|
||||
const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
|
||||
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
|
||||
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<BoundError | null>(null);
|
||||
const [sync, setSync] = useState<Sync>({
|
||||
domain: urlDomain,
|
||||
bounds: urlBounds,
|
||||
pendingDomain: [],
|
||||
pendingBounds: [],
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
const domainMoved = sync.domain !== urlDomain;
|
||||
const boundsMoved = !sameBounds(sync.bounds, urlBounds);
|
||||
if (domainMoved || boundsMoved) {
|
||||
let pendingDomain = sync.pendingDomain;
|
||||
let pendingBounds = sync.pendingBounds;
|
||||
|
||||
if (domainMoved) {
|
||||
// The newest commit the URL matches, and everything before it, has now
|
||||
// been accounted for; an older one landing later is not new news.
|
||||
const echo = pendingDomain.findLastIndex((sent) => sent === urlDomain);
|
||||
if (echo >= 0) {
|
||||
pendingDomain = pendingDomain.slice(echo + 1);
|
||||
} else {
|
||||
pendingDomain = [];
|
||||
setDomain(urlDomain);
|
||||
}
|
||||
}
|
||||
|
||||
if (boundsMoved) {
|
||||
const echo = pendingBounds.findLastIndex((sent) => sameBounds(sent, urlBounds));
|
||||
if (echo >= 0) {
|
||||
pendingBounds = pendingBounds.slice(echo + 1);
|
||||
} else {
|
||||
// Someone else moved the window — the back button, or a pasted link.
|
||||
// Whatever this form was showing about the old one is now wrong: the
|
||||
// preset it was named after, an error against bounds that are gone,
|
||||
// and a custom row that is open or shut for the wrong range.
|
||||
pendingBounds = [];
|
||||
setPreset(null);
|
||||
setError(null);
|
||||
setShowCustom(urlBounds.since !== undefined || urlBounds.until !== undefined);
|
||||
setSince(datetimeField(urlBounds.since));
|
||||
setUntil(datetimeField(urlBounds.until));
|
||||
}
|
||||
}
|
||||
|
||||
setSync({ domain: urlDomain, bounds: urlBounds, pendingDomain, pendingBounds });
|
||||
}
|
||||
|
||||
const dirty = textFilter(domain) !== applied.domain;
|
||||
|
||||
const commitDomain = useCallback(
|
||||
(replace: boolean) => {
|
||||
const next = textFilter(domain);
|
||||
setSync((prev) => ({ ...prev, pendingDomain: [...prev.pendingDomain, next ?? ""] }));
|
||||
onApply({ domain: next }, replace);
|
||||
},
|
||||
[domain, onApply],
|
||||
);
|
||||
|
||||
function commitBounds(bounds: Bounds) {
|
||||
setSync((prev) => ({ ...prev, pendingBounds: [...prev.pendingBounds, bounds] }));
|
||||
onApply(bounds);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const id = setTimeout(() => commitDomain(true), DEBOUNCE_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [dirty, commitDomain]);
|
||||
|
||||
function flush(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (dirty) commitDomain(true);
|
||||
}
|
||||
|
||||
function selectPreset(label: string) {
|
||||
if (label === CUSTOM_ITEM) {
|
||||
setShowCustom(true);
|
||||
return;
|
||||
}
|
||||
const option = PRESETS.find((candidate) => candidate.label === label);
|
||||
if (option === undefined) return;
|
||||
setShowCustom(false);
|
||||
setError(null);
|
||||
if (option.seconds === null) {
|
||||
setPreset(null);
|
||||
setSince(datetimeField(undefined));
|
||||
setUntil(datetimeField(undefined));
|
||||
commitBounds({ since: undefined, until: undefined });
|
||||
return;
|
||||
}
|
||||
// Both bounds, resolved once, here. An open upper bound would keep taking
|
||||
// in queries logged after the reader stopped looking, so "the past hour"
|
||||
// would name a different hour every time the link was opened.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const bounds: Bounds = { since: now - option.seconds, until: now };
|
||||
setPreset({ label, ...bounds });
|
||||
setSince(datetimeField(bounds.since));
|
||||
setUntil(datetimeField(bounds.until));
|
||||
commitBounds(bounds);
|
||||
}
|
||||
|
||||
function setRange() {
|
||||
const sinceValue = resolveDatetimeField(since);
|
||||
if (!sinceValue.ok) {
|
||||
setError(boundError("Since", sinceValue.reason));
|
||||
setError(boundError("since", sinceValue.reason));
|
||||
return;
|
||||
}
|
||||
const untilValue = resolveDatetimeField(until);
|
||||
if (!untilValue.ok) {
|
||||
setError(boundError("Until", untilValue.reason));
|
||||
setError(boundError("until", untilValue.reason));
|
||||
return;
|
||||
}
|
||||
// The window is half-open — `ts >= since` and `ts < until` — so two equal
|
||||
// bounds are as empty as an inverted pair, and neither is worth applying.
|
||||
if (sinceValue.value !== undefined && untilValue.value !== undefined && untilValue.value <= sinceValue.value) {
|
||||
setError({
|
||||
field: "until",
|
||||
message: "Until must be after Since, or the range selects nothing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onApply({
|
||||
domain: domain.trim() === "" ? undefined : domain.trim(),
|
||||
client: client.trim() === "" ? undefined : client.trim(),
|
||||
blocked: optionBlocked(blocked),
|
||||
since: sinceValue.value,
|
||||
until: untilValue.value,
|
||||
});
|
||||
setPreset(null);
|
||||
commitBounds({ since: sinceValue.value, until: untilValue.value });
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setDomain("");
|
||||
setClient("");
|
||||
setBlocked("any");
|
||||
setPreset(null);
|
||||
setShowCustom(false);
|
||||
setSince(datetimeField(undefined));
|
||||
setUntil(datetimeField(undefined));
|
||||
setError(null);
|
||||
setSync((prev) => ({
|
||||
...prev,
|
||||
pendingDomain: [],
|
||||
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
|
||||
}));
|
||||
onClear();
|
||||
}
|
||||
|
||||
const active =
|
||||
domain.trim() !== "" ||
|
||||
clients.length > 0 ||
|
||||
applied.blocked !== undefined ||
|
||||
applied.since !== undefined ||
|
||||
applied.until !== undefined;
|
||||
|
||||
// The label the URL earns on its own, overridden only while a preset click is
|
||||
// still the whole of what the URL says. Bounds nobody here chose read as
|
||||
// "Custom": that is what a pasted link or an edited range is.
|
||||
let timeLabel = "Any time";
|
||||
if (applied.since !== undefined || applied.until !== undefined) {
|
||||
timeLabel = preset !== null && sameBounds(preset, urlBounds) ? preset.label : "Custom";
|
||||
}
|
||||
|
||||
/** True for the one bound the current message is about; nothing else is marked. */
|
||||
const invalid = (field: BoundError["field"]): true | undefined =>
|
||||
error !== null && error.field === field ? true : undefined;
|
||||
|
||||
function boundInput(field: BoundError["field"]) {
|
||||
const state = field === "since" ? since : until;
|
||||
const set = field === "since" ? setSince : setUntil;
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={submit} {...stylex.props(styles.grid)}>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Domain contains
|
||||
<label {...stylex.props(styles.customField)}>
|
||||
{field === "since" ? "Since" : "Until"}
|
||||
<input
|
||||
type="text"
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={state.text}
|
||||
aria-invalid={invalid(field)}
|
||||
aria-describedby={invalid(field) && ERROR_ID}
|
||||
onChange={(event) => set(editDatetimeField(state, event.target.value))}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
// Enter here means this range, not the toolbar's text filters:
|
||||
// the two bounds only ever apply together, and the outer form's
|
||||
// submit would apply neither of them.
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
setRange();
|
||||
}}
|
||||
onBlur={() => {
|
||||
const resolved = resolveDatetimeField(state);
|
||||
if (!resolved.ok) setError(boundError(field, resolved.reason));
|
||||
else if (invalid(field)) setError(null);
|
||||
}}
|
||||
{...stylex.props(shared.smallInput, styles.field, shared.focusRing)}
|
||||
/>
|
||||
{invalid(field) && (
|
||||
<span id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
|
||||
{error?.message}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={flush}>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<div {...stylex.props(styles.searchWrap)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
|
||||
<MagnifyingGlass size={14} />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Filter domains"
|
||||
placeholder="Filter domains…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={domain}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Client (exact)
|
||||
<input
|
||||
type="text"
|
||||
value={client}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
</div>
|
||||
<ClientFilter
|
||||
options={clientOptions}
|
||||
selected={clients}
|
||||
onChange={(next) => onApply({ client: joinClients(next) })}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
variant="compactField"
|
||||
label="Result"
|
||||
value={blocked}
|
||||
isDisabled={isDisabled}
|
||||
onChange={setBlocked}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Since
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={since.text}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Until
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={until.text}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
<RadioGroup
|
||||
aria-label="Result"
|
||||
orientation="horizontal"
|
||||
value={blockedOption(applied.blocked)}
|
||||
onChange={(next) => onApply({ blocked: optionBlocked(next) })}
|
||||
className={() => stylex.props(styles.resultGroup).className ?? ""}
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
{RESULTS.map((option) => (
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.segment,
|
||||
isSelected ? styles.segmentSelected : styles.segmentIdle,
|
||||
isFocusVisible && styles.segmentFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
className={() =>
|
||||
stylex.props(shared.button, styles.hitTarget, shared.focusRing).className ?? ""
|
||||
}
|
||||
>
|
||||
Time: {timeLabel}
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
<Menu {...stylex.props(styles.menu)}>
|
||||
{[...PRESETS.map((option) => option.label), CUSTOM_ITEM].map((label) => (
|
||||
<MenuItem
|
||||
key={label}
|
||||
onAction={() => selectPreset(label)}
|
||||
className={({ isFocused }) =>
|
||||
stylex.props(
|
||||
styles.menuItem,
|
||||
shared.insetFocusRing,
|
||||
isFocused && styles.menuItemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
disabled={isDisabled}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
tabIndex={active ? undefined : -1}
|
||||
aria-hidden={active ? undefined : true}
|
||||
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing, !active && styles.clearHidden)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error !== null && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{error}
|
||||
</p>
|
||||
{showCustom && (
|
||||
<div {...stylex.props(styles.customRow)}>
|
||||
{boundInput("since")}
|
||||
{boundInput("until")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={setRange}
|
||||
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
|
||||
>
|
||||
Set range
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
@@ -85,7 +86,7 @@ function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
const VERSION = { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
/** The shell's own requests, which every test serves the same way. */
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
|
||||
@@ -135,6 +136,42 @@ function queryCalls(): string[] {
|
||||
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
||||
}
|
||||
|
||||
/** The toolbar's search field, which is how a domain filter is entered now. */
|
||||
function domainInput(): HTMLInputElement {
|
||||
return screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
}
|
||||
|
||||
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
|
||||
function submitFilters() {
|
||||
fireEvent.submit(domainInput().closest("form")!);
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer's count line, matched on the whole sentence.
|
||||
*
|
||||
* The number sits in a span of its own so it can carry tabular digits, so the
|
||||
* line is several text nodes and the default string matcher — which reads one
|
||||
* node at a time — cannot see it whole.
|
||||
*/
|
||||
function countMatcher(pattern: RegExp) {
|
||||
return (_: string, element: Element | null): boolean =>
|
||||
element?.tagName === "P" && pattern.test(element.textContent ?? "");
|
||||
}
|
||||
|
||||
function countLine(pattern: RegExp): HTMLElement {
|
||||
return screen.getByText(countMatcher(pattern));
|
||||
}
|
||||
|
||||
function findCountLine(pattern: RegExp): Promise<HTMLElement> {
|
||||
return screen.findByText(countMatcher(pattern));
|
||||
}
|
||||
|
||||
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
||||
function openCustomRange() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
}
|
||||
|
||||
test("renders the first page with the seven columns filled in", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
@@ -159,10 +196,10 @@ test("renders the first page with the seven columns filled in", async () => {
|
||||
// Route cell says how: this is the pair the old Status column could not show.
|
||||
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
|
||||
expect(within(blocked).getByText("—")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
expect(countLine(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
@@ -182,19 +219,22 @@ test("resolves each row's client to its display name, keeping the IP as the tool
|
||||
|
||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
// The address reads out with the name it replaced, rather than sitting in a
|
||||
// title only a mouse can reach.
|
||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||
expect(named.getAttribute("title")).toBeNull();
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with no tooltip standing in.
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
// seen, both fall back to the bare address with nothing standing in for it.
|
||||
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
|
||||
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
|
||||
});
|
||||
|
||||
test("load more appends the next page and stops at the end of the log", async () => {
|
||||
@@ -205,7 +245,7 @@ test("load more appends the next page and stops at the end of the log", async ()
|
||||
await screen.findByText("older.example");
|
||||
|
||||
expect(screen.getByText("first.example")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -216,10 +256,10 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
await findCountLine(/Showing 1 query /);
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
@@ -242,9 +282,9 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
await findCountLine(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
await act(async () => {
|
||||
@@ -252,7 +292,7 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
});
|
||||
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||
expect(countLine(/Showing 1 query /)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -281,8 +321,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
|
||||
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
||||
expect(staleButton).toHaveProperty("disabled", true);
|
||||
@@ -300,7 +340,7 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
||||
await screen.findByText("ads.older.example");
|
||||
|
||||
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
@@ -351,7 +391,7 @@ test("a background refetch after new rows arrive leaves no gap between the loade
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
@@ -416,7 +456,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
|
||||
renderPage("/activity?domain=ads");
|
||||
|
||||
await screen.findByText("ads.example");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
||||
expect(domainInput()).toHaveProperty("value", "ads");
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -441,7 +481,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
|
||||
await screen.findByText("first.example");
|
||||
// Nothing survived validation, so the request is the unfiltered one.
|
||||
expect(queryCalls()).toEqual(["/api/queries"]);
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the form draft follows the url back and forward, seconds included", async () => {
|
||||
@@ -454,25 +494,33 @@ test("the form draft follows the url back and forward, seconds included", async
|
||||
const seeded = 1_700_000_017;
|
||||
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
||||
|
||||
const domainInput = await screen.findByLabelText("Domain contains");
|
||||
expect(domainInput).toHaveProperty("value", "first");
|
||||
await screen.findByLabelText("Filter domains");
|
||||
expect(domainInput()).toHaveProperty("value", "first");
|
||||
// A seeded custom range opens its row, so the link's bounds are visible.
|
||||
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
expect(sinceInput.value).toContain(":37");
|
||||
|
||||
fireEvent.change(domainInput, { target: { value: "second" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "second" } });
|
||||
submitFilters();
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
||||
// The untouched Since bound applied as the exact second it was seeded with.
|
||||
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
|
||||
|
||||
// A pasted link, then the buttons over it: the draft is derived from the URL,
|
||||
// so whichever way the browser moves it the field has to move with it.
|
||||
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
|
||||
await waitFor(() => {
|
||||
expect(domainInput()).toHaveProperty("value", "third");
|
||||
});
|
||||
|
||||
act(() => history.back());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
|
||||
expect(domainInput()).toHaveProperty("value", "second");
|
||||
});
|
||||
|
||||
act(() => history.forward());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
|
||||
expect(domainInput()).toHaveProperty("value", "third");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -508,8 +556,9 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
|
||||
const callsBefore = queryCalls().length;
|
||||
const searchBefore = history.location.search;
|
||||
|
||||
openCustomRange();
|
||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
|
||||
if (inGap) {
|
||||
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
||||
@@ -544,6 +593,40 @@ test("the policy simulation is reachable from the header, with no rows to click
|
||||
expect(history.location.pathname).toBe("/activity/test");
|
||||
});
|
||||
|
||||
test("the mode switch is a tab list whose selection is the url, and it keeps the filters", async () => {
|
||||
// Live opens a stream as soon as its panel mounts, so the switch cannot be
|
||||
// exercised without one; the fake stands in for the browser's EventSource.
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
constructor(url: string) {
|
||||
return new FakeEventSource(url) as unknown as EventSource;
|
||||
}
|
||||
},
|
||||
);
|
||||
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||
await screen.findByText("ads.example");
|
||||
|
||||
const tabs = within(screen.getByRole("tablist", { name: "Activity mode" }));
|
||||
expect(tabs.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["History", "Live"]);
|
||||
expect(tabs.getByRole("tab", { name: "History", selected: true })).toBeTruthy();
|
||||
expect(tabs.getByRole("tab", { name: "Live", selected: false })).toBeTruthy();
|
||||
|
||||
fireEvent.click(tabs.getByRole("tab", { name: "Live" }));
|
||||
|
||||
await waitFor(() => expect(history.location.search).toContain("mode=live"));
|
||||
// The investigation survives the switch: both filters are still in the URL.
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
expect(history.location.search).toContain("blocked=true");
|
||||
expect(
|
||||
within(screen.getByRole("tablist", { name: "Activity mode" })).getByRole("tab", {
|
||||
name: "Live",
|
||||
selected: true,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Clear empties the url as well as the form", async () => {
|
||||
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||
await screen.findByText("ads.example");
|
||||
@@ -553,5 +636,162 @@ test("Clear empties the url as well as the form", async () => {
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
});
|
||||
expect(history.location.search).not.toContain("blocked");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the empty result offers the clear it names, and it clears the same filters the toolbar does", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url === "/api/queries") return json(PAGES["/api/queries"]);
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
const { history } = renderPage("/activity?mode=history&domain=nothing&blocked=true");
|
||||
await screen.findByText("No queries match the current filters.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
});
|
||||
expect(history.location.search).not.toContain("blocked");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
// The unfiltered log is back, so the button did the toolbar's Clear and not a
|
||||
// reset of its own that only emptied the form.
|
||||
expect(await screen.findByText("first.example")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an empty log offers no clear, because there is no filter to blame for it", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("No queries logged yet.");
|
||||
expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(domainInput(), { target: { value: "a" } });
|
||||
fireEvent.change(domainInput(), { target: { value: "ad" } });
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
// Mid-word the URL has not moved: three keystrokes are one investigation,
|
||||
// not three, and each one would otherwise be a request and a history entry.
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
// Replaced, not pushed: Back leaves the page, it does not retype the word.
|
||||
expect(history.length).toBe(1);
|
||||
|
||||
fireEvent.change(domainInput(), { target: { value: "first" } });
|
||||
submitFilters();
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=first"));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("the field being typed in keeps the focus when the debounce commits", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
const input = domainInput();
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value: "ads" } });
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=ads"));
|
||||
|
||||
// The same node, still focused, still holding the caret: a toolbar that
|
||||
// remounted on the URL it just wrote would drop the next keystroke.
|
||||
expect(domainInput()).toBe(input);
|
||||
expect(document.activeElement).toBe(input);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a result segment commits on the click, with no wait and no button", async () => {
|
||||
const { history } = renderPage("/activity?domain=ads");
|
||||
await screen.findByText("ads.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
|
||||
await waitFor(() => expect(history.location.search).toContain("blocked=true"));
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
});
|
||||
|
||||
test("a time preset writes the second it resolved to, not a rolling window", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("No queries logged yet.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Time: Any time" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
|
||||
// Both bounds are concrete, so the link names a closed hour rather than one
|
||||
// that keeps growing at the top as the log does.
|
||||
const since = now / 1000 - 3600;
|
||||
const until = now / 1000;
|
||||
await waitFor(() => expect(history.location.search).toContain(`since=${since}`));
|
||||
expect(history.location.search).toContain(`until=${until}`);
|
||||
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
|
||||
expect(queryCalls()).toContain(`/api/queries?since=${since}&until=${until}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("Live shows no toolbar at all", async () => {
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
constructor(url: string) {
|
||||
return new FakeEventSource(url) as unknown as EventSource;
|
||||
}
|
||||
},
|
||||
);
|
||||
renderPage("/activity?mode=live&domain=ads");
|
||||
await screen.findByText(/the History filters apply to history only/);
|
||||
|
||||
// The stream is unfiltered, so a control here would promise filtering that is
|
||||
// not happening; the filters are still in the URL, waiting for History.
|
||||
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
||||
});
|
||||
|
||||
test("the coverage watermark reads under the results, never over them", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [row(20, "kept.example")],
|
||||
next_before: null,
|
||||
coverage: { complete: false, available_since: 1_700_000_000 },
|
||||
} satisfies QueriesPage);
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText("kept.example");
|
||||
|
||||
const watermark = screen.getByText(/Query history is available from/);
|
||||
const count = countLine(/Showing 1 query/);
|
||||
// After the count in document order, which is what "footer" means here.
|
||||
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
* recipient should be looking at.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||
import HistoryActivity from "./HistoryActivity";
|
||||
import LiveActivity from "./LiveActivity";
|
||||
@@ -33,11 +35,12 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
switch: {
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -53,6 +56,13 @@ const styles = stylex.create({
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
},
|
||||
/** A Tab is a `div` with a roving tabindex, so RAC drives the ring, not `:focus-visible`. */
|
||||
modeFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
modeIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||
@@ -76,13 +86,33 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
panel: {
|
||||
outlineStyle: "none",
|
||||
},
|
||||
/**
|
||||
* A panel with nothing tabbable in it — an empty or loading history — is given
|
||||
* a tabindex by RAC so the reader can still reach its content, so it has to be
|
||||
* able to show that it holds the focus.
|
||||
*/
|
||||
panelFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
|
||||
tabsRoot: {
|
||||
display: "block",
|
||||
},
|
||||
});
|
||||
|
||||
function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
|
||||
return stylex.props(styles.panel, isFocusVisible && styles.panelFocusVisible).className ?? "";
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const search = useSearch({ from: "/shell/activity" });
|
||||
const navigate = useNavigate({ from: "/activity" });
|
||||
const live = search.mode === "live";
|
||||
|
||||
// The functional form, not a replacement object: the filters are retained
|
||||
// across a mode switch on purpose, and spelling out a new search here would
|
||||
// drop every one of them on the way to Live and back.
|
||||
@@ -91,62 +121,72 @@ export default function ActivityPage() {
|
||||
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
||||
}
|
||||
|
||||
function apply(filters: AppliedFilters) {
|
||||
void navigate({ search: { mode: search.mode, ...filters } });
|
||||
}
|
||||
// A patch, merged into whatever the URL already says: a segment click must not
|
||||
// spell out the four filters it is not about. `replace` is the debounced text
|
||||
// commits, so the back button steps between investigations, not keystrokes.
|
||||
const apply = useCallback(
|
||||
(patch: Partial<AppliedFilters>, replace = false) => {
|
||||
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/*
|
||||
* The selected tab is the URL's `mode` and nothing else. RAC would hold
|
||||
* the selection itself, but a second copy of it would fight the back
|
||||
* button, so the search parameter stays the only state there is.
|
||||
*/}
|
||||
<Tabs
|
||||
selectedKey={search.mode}
|
||||
onSelectionChange={(key) => selectMode(key as ActivityMode)}
|
||||
className={() => stylex.props(styles.tabsRoot).className ?? ""}
|
||||
>
|
||||
<div {...stylex.props(styles.header)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
||||
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
|
||||
{MODES.map((option) => {
|
||||
const selected = option.mode === search.mode;
|
||||
return (
|
||||
<button
|
||||
<TabList aria-label="Activity mode" className={() => stylex.props(styles.switch).className ?? ""}>
|
||||
{MODES.map((option) => (
|
||||
<Tab
|
||||
key={option.mode}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => selectMode(option.mode)}
|
||||
{...stylex.props(
|
||||
id={option.mode}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.modeButton,
|
||||
selected ? styles.modeSelected : styles.modeIdle,
|
||||
shared.focusRing,
|
||||
)}
|
||||
isSelected ? styles.modeSelected : styles.modeIdle,
|
||||
isFocusVisible && styles.modeFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
||||
Current policy simulation
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Remounted whenever the applied search changes, which is what makes
|
||||
* the back button work: the draft is derived state, and the browser
|
||||
* moving the URL under it has to move the form with it.
|
||||
* The toolbar belongs to History alone. It is not remounted on a search
|
||||
* change: it resyncs its draft from the URL instead, because a remount
|
||||
* mid-debounce would take the focus out of the input being typed in.
|
||||
*/}
|
||||
<ActivityFilters
|
||||
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
|
||||
applied={search}
|
||||
isDisabled={live}
|
||||
onApply={apply}
|
||||
onClear={() => apply(NO_FILTERS)}
|
||||
/>
|
||||
|
||||
{live ? (
|
||||
<>
|
||||
<TabPanel id="history" className={panelClass}>
|
||||
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
||||
<HistoryActivity search={search} onClear={clear} />
|
||||
</TabPanel>
|
||||
<TabPanel id="live" className={panelClass}>
|
||||
<p {...stylex.props(styles.liveNote)}>
|
||||
The stream carries every query the server answers; these filters apply to history only.
|
||||
The stream carries every query the server answers; the History filters apply to history only.
|
||||
</p>
|
||||
<LiveActivity origin={search} />
|
||||
</>
|
||||
) : (
|
||||
<HistoryActivity search={search} />
|
||||
)}
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* The client filter: the loaded clients as a list you pick from, and the picked
|
||||
* ones as chips you can take back off.
|
||||
*
|
||||
* There is nothing to type here, deliberately. The filter is exact — the server
|
||||
* matches whole addresses — so a half-typed address is not a narrower filter but
|
||||
* a wrong one, and a field that applied as you typed emptied the table under
|
||||
* every reader who started with a digit. Choosing from a list cannot be
|
||||
* half-done: every state this control can be in is a filter someone meant.
|
||||
*
|
||||
* Several clients at once, because the question is usually about a group — the
|
||||
* two phones, the television and the console — and one address at a time makes
|
||||
* that several passes over the same window.
|
||||
*
|
||||
* The chips carry the whole selection, including addresses no client claims. The
|
||||
* log names devices the config has never heard of, and those are the ones an
|
||||
* operator is most likely hunting; a link filtered on one has to stay readable
|
||||
* and clearable even though the menu below cannot offer it.
|
||||
*
|
||||
* A list that has not loaded, or failed to, leaves the trigger disabled rather
|
||||
* than opening on nothing. Chips from the URL still show, so the filter stays
|
||||
* visible and removable either way.
|
||||
*/
|
||||
|
||||
import { CaretDown } from "@phosphor-icons/react/dist/icons/CaretDown";
|
||||
import { Check } from "@phosphor-icons/react/dist/icons/Check";
|
||||
import { X } from "@phosphor-icons/react/dist/icons/X";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
||||
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { MAX_CLIENTS } from "./search";
|
||||
|
||||
/**
|
||||
* One client as the picker shows it.
|
||||
*
|
||||
* `label` identifies it outright and `name` is the short form. The menu is a
|
||||
* list of every client at once, where two devices can share a name and only the
|
||||
* address tells them apart; a chip is one client the reader picked a moment ago,
|
||||
* beside a row of others, where the address is the part that does not fit.
|
||||
*/
|
||||
export interface ClientOption {
|
||||
ip: string;
|
||||
label: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many chips are shown before the rest become a count.
|
||||
*
|
||||
* The chips exist so an active filter is visible without opening the menu. A
|
||||
* dozen of them are not more visible than three — they wrap the toolbar into a
|
||||
* block of its own and push the table off the screen — so past this the row says
|
||||
* how many more there are and the menu remains where they are managed.
|
||||
*/
|
||||
const MAX_CHIPS = 3;
|
||||
|
||||
const styles = stylex.create({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
caret: {
|
||||
display: "inline-flex",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
trigger: {
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
popover: {
|
||||
maxHeight: "16rem",
|
||||
overflowY: "auto",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menu: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
item: {
|
||||
cursor: "pointer",
|
||||
paddingInline: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
whiteSpace: "nowrap",
|
||||
minHeight: metrics.hitTarget,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
/** Inset because an item flush against a scrolling popover clips an outset ring. */
|
||||
itemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
/** The tick keeps its column when absent, so the labels do not shift on select. */
|
||||
tick: {
|
||||
width: "0.75rem",
|
||||
flexShrink: 0,
|
||||
display: "inline-flex",
|
||||
},
|
||||
chip: {
|
||||
cursor: "pointer",
|
||||
transitionProperty: metrics.transitionProperty,
|
||||
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
minHeight: metrics.hitTarget,
|
||||
paddingInline: "0.625rem",
|
||||
borderRadius: "999px",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: { default: colors.surfaceHover, ":hover": colors.surfaceRaised },
|
||||
color: colors.text,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** A hostname can be longer than the toolbar; the cross stays outside the cut. */
|
||||
chipLabel: {
|
||||
maxWidth: "14rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
/** Decoration inside the button, so a click on it is a click on the button. */
|
||||
chipCross: {
|
||||
display: "inline-flex",
|
||||
pointerEvents: "none",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** Not a button: it removes nothing, and nothing about it is pressable. */
|
||||
chipMore: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
minHeight: metrics.hitTarget,
|
||||
paddingInline: "0.625rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
capNote: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
/** The em dash pairs the two halves without reading as part of either. */
|
||||
function optionLabel(ip: string, name: string | null): string {
|
||||
return name === null ? ip : `${name} — ${ip}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loaded clients, by the name the query tables already give them.
|
||||
*
|
||||
* This is the same cached query those tables read, so opening the list costs no
|
||||
* request; an empty map — still loading, or failed — yields no options.
|
||||
*/
|
||||
export function useClientOptions(): ClientOption[] {
|
||||
const names = useClientNames();
|
||||
return useMemo(() => {
|
||||
const options = [...names.keys()].map((ip) => {
|
||||
const name = clientLabel(ip, names)?.text ?? null;
|
||||
return { ip, name, label: optionLabel(ip, name) };
|
||||
});
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [names]);
|
||||
}
|
||||
|
||||
/** How an address reads in full: its client's line, or the bare address. */
|
||||
export function displayFor(ip: string, options: readonly ClientOption[]): string {
|
||||
return options.find((option) => option.ip === ip)?.label ?? ip;
|
||||
}
|
||||
|
||||
/** How an address reads on a chip: its client's name, or the bare address. */
|
||||
export function chipFor(ip: string, options: readonly ClientOption[]): string {
|
||||
return options.find((option) => option.ip === ip)?.name ?? ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL carries the selection as one comma-separated value; absent means none.
|
||||
*
|
||||
* A plain split, because `validateClients` has already made the value canonical
|
||||
* — trimmed, no blanks, no repeats, within the cap. Dropping anything a second
|
||||
* time here is what would let the chips and the request disagree.
|
||||
*/
|
||||
export function parseClients(value: string | undefined): string[] {
|
||||
if (value === undefined) return [];
|
||||
return value.split(",");
|
||||
}
|
||||
|
||||
export function joinClients(ips: readonly string[]): string | undefined {
|
||||
return ips.length === 0 ? undefined : ips.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger's label. A single client is named outright, because that is the
|
||||
* one case where the whole filter fits on the button; more than one would not,
|
||||
* and the chips beside it say which ones anyway.
|
||||
*/
|
||||
function triggerLabel(selected: readonly string[], options: readonly ClientOption[]): string {
|
||||
if (selected.length === 0) return "Clients";
|
||||
if (selected.length === 1) return displayFor(selected[0] as string, options);
|
||||
return `${formatCount(selected.length)} clients`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: readonly ClientOption[];
|
||||
selected: readonly string[];
|
||||
/** Every change here is a decision already made, so it applies at once. */
|
||||
onChange: (next: string[]) => void;
|
||||
}
|
||||
|
||||
export default function ClientFilter({ options, selected, onChange }: Props) {
|
||||
const known = new Set(options.map((option) => option.ip));
|
||||
// The menu is offered only the addresses it can account for. An address no
|
||||
// client claims is not in its collection, so handing it over as a selected key
|
||||
// would be handing over a key that resolves to nothing.
|
||||
const chosen = new Set(selected.filter((ip) => known.has(ip)));
|
||||
// Every address the URL carries takes a slot, whether or not a client claims
|
||||
// it: the cap is on what the request may name, not on what this menu can show.
|
||||
const atCap = selected.length >= MAX_CLIENTS;
|
||||
// True only when the cap is actually holding something back. Unknown addresses
|
||||
// spend slots too, so a short list of clients can be closed off while the menu
|
||||
// still has room in it — and a disabled row with nothing to explain it is the
|
||||
// one state this must not reach.
|
||||
const capBinds = atCap && chosen.size < options.length;
|
||||
|
||||
const chips = useRef(new Map<string, HTMLButtonElement>());
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
/** Where focus goes once the removed chip is gone; null means the trigger. */
|
||||
const focusAfterRemoval = useRef<string | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const next = focusAfterRemoval.current;
|
||||
if (next === undefined) return;
|
||||
focusAfterRemoval.current = undefined;
|
||||
// Removing a chip unmounts the element that had the focus. Left alone the
|
||||
// browser drops focus to the document, and a reader clearing three clients
|
||||
// from the keyboard would have to tab back in from the top each time.
|
||||
(next === null ? trigger.current : (chips.current.get(next) ?? trigger.current))?.focus();
|
||||
});
|
||||
|
||||
function remove(ip: string) {
|
||||
const next = selected.filter((entry) => entry !== ip);
|
||||
const visible = next.slice(0, MAX_CHIPS);
|
||||
const index = selected.indexOf(ip);
|
||||
// The chip that takes this one's place, or the one before it at the end of
|
||||
// the row, or the trigger when the row is empty.
|
||||
focusAfterRemoval.current = visible[index] ?? visible[index - 1] ?? null;
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* A selection from the menu, merged back over the addresses the menu could not
|
||||
* see and turned into what the URL should carry.
|
||||
*
|
||||
* What is picked is what is committed, always — even every known client at
|
||||
* once. The tick, the chip and the URL then say what the reader did, and a
|
||||
* picker whose feedback for "you selected everything" is to erase the
|
||||
* selection reads as a control that ignored the click; with one client on the
|
||||
* network that is every click it will ever get. Nor is the full set a no-op:
|
||||
* the history keeps rows for clients the inventory has since pruned, so "all
|
||||
* known clients" and "no filter" are different questions.
|
||||
*
|
||||
* The cap is enforced here as well as on the items, because select-all reaches
|
||||
* this without passing an item at all. What was already filtered keeps its
|
||||
* slots and the new picks take what is left, so a selection too big to send is
|
||||
* cut somewhere the reader can predict rather than wherever the URL ran out.
|
||||
*/
|
||||
function apply(next: Set<string>) {
|
||||
const kept = selected.filter((ip) => !known.has(ip) || next.has(ip));
|
||||
const added = [...next].filter((ip) => !chosen.has(ip));
|
||||
onChange([...kept, ...added].slice(0, MAX_CLIENTS));
|
||||
}
|
||||
|
||||
return (
|
||||
// The picker and the chips it fills are one control between them: the group
|
||||
// says so, and names the chips' "Remove …" buttons as part of it.
|
||||
<div role="group" aria-label="Clients" {...stylex.props(styles.root)}>
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
// Nothing to open, and a trigger that opened on an empty popover would
|
||||
// promise a list that is not there.
|
||||
ref={trigger}
|
||||
isDisabled={options.length === 0}
|
||||
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
|
||||
>
|
||||
{triggerLabel(selected, options)}
|
||||
<span aria-hidden="true" {...stylex.props(styles.caret)}>
|
||||
<CaretDown size={12} />
|
||||
</span>
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
{/* Said where it is doing something, and nowhere else. */}
|
||||
{capBinds && <p {...stylex.props(styles.capNote)}>At most {MAX_CLIENTS} clients at a time.</p>}
|
||||
<Menu
|
||||
aria-label="Clients"
|
||||
selectionMode="multiple"
|
||||
selectedKeys={chosen}
|
||||
onSelectionChange={(keys) => {
|
||||
// Ctrl/Cmd+A hands back the literal "all"; taking every known
|
||||
// client is its only reading, and apply() caps the result.
|
||||
if (keys === "all") apply(new Set(options.map((option) => option.ip)));
|
||||
else apply(new Set([...keys].map(String)));
|
||||
}}
|
||||
autoFocus={false}
|
||||
{...stylex.props(styles.menu)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.ip}
|
||||
id={option.ip}
|
||||
// At the cap, what is already picked can still be unpicked and
|
||||
// nothing else can be added. A menu that took a 33rd pick and
|
||||
// dropped it would look like it had worked.
|
||||
isDisabled={atCap && !chosen.has(option.ip)}
|
||||
textValue={option.label}
|
||||
className={({ isFocused }) =>
|
||||
stylex.props(styles.item, shared.insetFocusRing, isFocused && styles.itemFocused)
|
||||
.className ?? ""
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true" {...stylex.props(styles.tick)}>
|
||||
{chosen.has(option.ip) && <Check size={12} />}
|
||||
</span>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
{selected.slice(0, MAX_CHIPS).map((ip) => (
|
||||
<button
|
||||
key={ip}
|
||||
type="button"
|
||||
ref={(node) => {
|
||||
if (node === null) chips.current.delete(ip);
|
||||
else chips.current.set(ip, node);
|
||||
}}
|
||||
// The chip reads as a name and removes an address, so the name alone
|
||||
// would not say what the button does to a reader who cannot see it.
|
||||
aria-label={`Remove client ${displayFor(ip, options)}`}
|
||||
// The chip label is cut to fit the row, so the pointer can still read
|
||||
// the whole of what it names.
|
||||
title={displayFor(ip, options)}
|
||||
onClick={() => remove(ip)}
|
||||
{...stylex.props(styles.chip, shared.focusRing)}
|
||||
>
|
||||
<span {...stylex.props(styles.chipLabel)}>{chipFor(ip, options)}</span>
|
||||
<span aria-hidden="true" {...stylex.props(styles.chipCross)}>
|
||||
<X size={10} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{selected.length > MAX_CHIPS && (
|
||||
<span {...stylex.props(styles.chipMore)}>+{formatCount(selected.length - MAX_CHIPS)} more</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,24 +12,34 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import * as api from "@/lib/api";
|
||||
import CoverageNotice from "@/lib/CoverageNotice";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueryRow } from "@/lib/types";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { summarizeRow } from "@/features/provenance/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
import { queriesFilterOf, type ActivitySearch } from "./search";
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
marginTop: "1.5rem",
|
||||
minHeight: "6rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.75rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** About a default page of rows, so the table does not jump in under the reader. */
|
||||
loading: {
|
||||
minHeight: "24rem",
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -56,7 +66,8 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
refetching: {
|
||||
/** The gap a line standing on its own needs from the block above it. */
|
||||
spacedTop: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
moreButton: {
|
||||
@@ -74,14 +85,22 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export default function HistoryActivity({ search }: { search: ActivitySearch }) {
|
||||
interface Props {
|
||||
search: ActivitySearch;
|
||||
/** The toolbar's own Clear, so the empty state offers the way out it names. */
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function HistoryActivity({ search, onClear }: Props) {
|
||||
const filter = queriesFilterOf(search);
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
||||
const clientNames = useClientNames();
|
||||
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const coverage = pages[0]?.coverage;
|
||||
// Only from the settled response. Placeholder pages belong to the previous
|
||||
// filter, and a watermark is a claim about the window being displayed.
|
||||
const coverage = base.isPlaceholderData ? undefined : pages[0]?.coverage;
|
||||
const filterActive = Object.keys(filter).length > 0;
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
@@ -104,7 +123,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
}
|
||||
if (base.data === undefined) {
|
||||
return (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
<p {...stylex.props(styles.empty, styles.loading, shared.pulse)} role="status">
|
||||
Loading activity…
|
||||
</p>
|
||||
);
|
||||
@@ -112,19 +131,29 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
* A quiet line, never a skeleton: the rows on screen stay put while a
|
||||
* filter change is in flight, so a keystroke must not blank the table
|
||||
* it is narrowing.
|
||||
*/}
|
||||
{base.isFetching && (
|
||||
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
||||
Loading…
|
||||
<p {...stylex.props(styles.note, styles.spacedTop)} role="status">
|
||||
Updating…
|
||||
</p>
|
||||
)}
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||
{rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
<div {...stylex.props(styles.empty)}>
|
||||
<p>{filterActive ? "No queries match the current filters." : "No queries logged yet."}</p>
|
||||
{filterActive && (
|
||||
<button type="button" onClick={onClear} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
@@ -155,9 +184,12 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{/* The count is the one part of this line that moves as pages load. */}
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{formatCount(rows.length)}</span>{" "}
|
||||
{rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -48,7 +48,7 @@ const CLIENTS: Client[] = [
|
||||
client("192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
const VERSION = { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
let sources: FakeEventSource[];
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
@@ -57,7 +57,17 @@ function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
|
||||
/**
|
||||
* The empty history page. Live mode asks for no query pages, but switching back
|
||||
* to History does, and a page-shaped response is the only honest answer there.
|
||||
*/
|
||||
const EMPTY_PAGE = { queries: [], next_before: null, coverage: { complete: true, available_since: 0 } };
|
||||
|
||||
function defaultHandler(url: string): Response {
|
||||
return json(url === "/api/queries" || url.startsWith("/api/queries?") ? EMPTY_PAGE : {});
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response> = defaultHandler) {
|
||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||
@@ -168,7 +178,7 @@ test("streams rows, flags blocked ones, and freezes the display", async () => {
|
||||
expect(screen.getByText("later.example")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||
await openLive();
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
|
||||
@@ -178,11 +188,14 @@ test("resolves each row's client to its display name, keeping the IP as the tool
|
||||
});
|
||||
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
// The address reads out with the name it replaced, rather than sitting in a
|
||||
// title only a mouse can reach.
|
||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||
expect(named.getAttribute("title")).toBeNull();
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
@@ -256,7 +269,40 @@ test("a recovered row links to its stored detail; a streamed one opens in place
|
||||
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a streamed row opens its own provenance, from the keyboard as well as the pointer", async () => {
|
||||
/** The open detail. Named by its heading, so the query proves the name is visible. */
|
||||
function detailDialog(): HTMLElement {
|
||||
return screen.getByRole("dialog", { name: "Streamed query" });
|
||||
}
|
||||
|
||||
/** React Aria's ModalOverlay, two levels out from the dialog it wraps. */
|
||||
function backdrop(): HTMLElement {
|
||||
return detailDialog().parentElement!.parentElement!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a control the way a keyboard does. jsdom runs no default action for
|
||||
* Enter on a button, so the click a browser would then dispatch is issued here;
|
||||
* `detail: 0` is what marks it as keyboard-driven rather than pointer-driven,
|
||||
* and is the flag React Aria itself reads.
|
||||
*/
|
||||
function pressWithKeyboard(control: HTMLElement) {
|
||||
act(() => control.focus());
|
||||
fireEvent.keyDown(control, { key: "Enter" });
|
||||
fireEvent.click(control, { detail: 0 });
|
||||
fireEvent.keyUp(control, { key: "Enter" });
|
||||
}
|
||||
|
||||
/** Activate a control the way a mouse does, through the full pointer sequence. */
|
||||
function pressWithMouse(control: HTMLElement) {
|
||||
fireEvent.pointerDown(control, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.pointerUp(control, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.click(control, { detail: 1 });
|
||||
}
|
||||
|
||||
test.each([
|
||||
["the pointer", pressWithMouse],
|
||||
["the keyboard", pressWithKeyboard],
|
||||
])("a streamed row opens its provenance in a named dialog, from %s", async (_label, press) => {
|
||||
await openLive();
|
||||
act(() =>
|
||||
sources[0]!.emit(
|
||||
@@ -273,69 +319,108 @@ test("a streamed row opens its own provenance, from the keyboard as well as the
|
||||
// only way to lose that is to opt out of it, which nothing here may do.
|
||||
expect(trigger.tagName).toBe("BUTTON");
|
||||
expect(trigger.getAttribute("tabindex")).toBeNull();
|
||||
act(() => trigger.focus());
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
// The row opens a dialog, so it says so; what it no longer claims is to
|
||||
// expand a region that stays in the page.
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBeNull();
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
const heading = screen.getByRole("heading", { level: 1, name: "streamed.example" });
|
||||
expect(heading).toBeTruthy();
|
||||
const panel = heading.closest("div")!.parentElement!;
|
||||
expect(within(panel).getByText("Blocked locally")).toBeTruthy();
|
||||
expect(panel.textContent).toContain("the query log may not have written it yet");
|
||||
press(trigger);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
|
||||
const dialog = detailDialog();
|
||||
expect(within(dialog).getByRole("heading", { level: 1, name: "streamed.example" })).toBeTruthy();
|
||||
expect(within(dialog).getByText("Blocked locally")).toBeTruthy();
|
||||
expect(dialog.textContent).toContain("the query log may not have written it yet");
|
||||
// React Aria may defer the move by a frame, depending on the modality it read
|
||||
// from the activation, so the wait is the assertion rather than a workaround.
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
expect(within(dialog).getByRole("button", { name: "Close" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
|
||||
test("tabbing forward and backward stays inside the open dialog", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
const dialog = detailDialog();
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
// The related links land asynchronously; tabbing before them would walk a
|
||||
// shorter dialog than the reader ever sees.
|
||||
await waitFor(() => expect(within(dialog).getAllByRole("link").length).toBeGreaterThan(1));
|
||||
|
||||
// A native button activates on Enter and Space; jsdom does not synthesize
|
||||
// the click those keys fire, so the click is the activation.
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
|
||||
// The panel is inserted above the table, behind the trigger in tab order, so
|
||||
// the only thing that keeps a forward tab inside it is focus moving in.
|
||||
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
const visited = new Set<Element>();
|
||||
for (const shiftKey of [false, false, false, false, false, false, true, true, true, true]) {
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Tab", shiftKey });
|
||||
fireEvent.keyUp(document.activeElement!, { key: "Tab", shiftKey });
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
visited.add(document.activeElement!);
|
||||
}
|
||||
// Containment that never moved focus would satisfy the check above without
|
||||
// trapping anything, so the walk has to have actually walked.
|
||||
expect(visited.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("opening a second row moves the expanded state and the focus with it", async () => {
|
||||
test.each([
|
||||
["the Close button", () => fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }))],
|
||||
["Escape", () => fireEvent.keyDown(detailDialog(), { key: "Escape" })],
|
||||
[
|
||||
"a click on the backdrop",
|
||||
() => {
|
||||
const overlay = backdrop();
|
||||
fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.pointerUp(overlay, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.click(overlay, { detail: 1 });
|
||||
},
|
||||
],
|
||||
])("%s closes the dialog and returns focus to the row that opened it", async (_label, dismiss) => {
|
||||
await openLive();
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "first.example"));
|
||||
sources[0]!.emit("query", frame(1001, "second.example"));
|
||||
});
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
expect(detailDialog()).toBeTruthy();
|
||||
|
||||
const first = screen.getByRole("button", { name: "first.example" });
|
||||
const second = screen.getByRole("button", { name: "second.example" });
|
||||
fireEvent.click(first);
|
||||
fireEvent.click(second);
|
||||
dismiss();
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(first.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(second.getAttribute("aria-expanded")).toBe("true");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
|
||||
});
|
||||
|
||||
test("the stream runs on behind the open dialog, and its rows land in the table on close", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
const snapshot = detailDialog().textContent;
|
||||
act(() => sources[0]!.emit("query", frame(1001, "arrived-while-open.example")));
|
||||
|
||||
// The connection is untouched: no close, no second EventSource.
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(false);
|
||||
// And the snapshot is a snapshot: nothing that arrives rewrites it.
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
|
||||
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "arrived-while-open.example" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the rows and toolbar behind the dialog are out of reach while it is open", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
// React Aria hides everything outside the modal from assistive technology
|
||||
// and from the pointer alike, so the row and the toolbar are unreachable by
|
||||
// role: nothing behind the dialog can be operated while it is open.
|
||||
expect(screen.queryByRole("button", { name: "streamed.example" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Freeze" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(document.activeElement).toBe(second);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Freeze" })).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -359,23 +444,57 @@ function renderLiveWithCapacity(capacity: number) {
|
||||
);
|
||||
}
|
||||
|
||||
test("an open streamed detail survives the row being evicted from the ring buffer", () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// One ringful more: the ring keeps the newest 5, so the selected row is gone
|
||||
// from the table. The detail is a snapshot, not a lookup into the ring.
|
||||
/** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
|
||||
function evictWithFiller() {
|
||||
act(() => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("an open dialog's snapshot survives its row being evicted from the ring buffer", async () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
const snapshot = detailDialog().textContent;
|
||||
expect(within(detailDialog()).getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// One ringful more: the ring keeps the newest 5, so the selected row is gone.
|
||||
// The dialog holds the frame itself, not a lookup into the ring, so it neither
|
||||
// blanks out nor closes.
|
||||
evictWithFiller();
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
|
||||
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.getAllByRole("row")).toHaveLength(6);
|
||||
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("closing after the source row is evicted anchors focus in the results region", async () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
const trigger = screen.getByRole("button", { name: "evicted.example" });
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
|
||||
evictWithFiller();
|
||||
expect(trigger.isConnected).toBe(false);
|
||||
|
||||
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
|
||||
// Never the body, never whichever row happens to sit where the old one did,
|
||||
// and never the Freeze button: a stable anchor in the region the reader was
|
||||
// reading. React Aria's own deferred restore runs after this and, finding
|
||||
// focus already placed, leaves it alone.
|
||||
const region = screen.getByRole("region", { name: "Live queries" });
|
||||
expect(document.activeElement).toBe(region);
|
||||
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
|
||||
expect(document.activeElement).toBe(region);
|
||||
});
|
||||
|
||||
test("the route renders the live ring at its production capacity", async () => {
|
||||
@@ -391,34 +510,29 @@ test("an open streamed detail survives Freeze and Resume", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
||||
const snapshot = detailDialog().textContent;
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
// Freeze is behind the dialog, so it is reached the way the code reaches it
|
||||
// rather than by role, which the modal deliberately hides.
|
||||
const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
|
||||
act(() => freeze().click());
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
act(() => (screen.getByText("Resume") as HTMLButtonElement).click());
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
});
|
||||
|
||||
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
|
||||
test("live renders no filter toolbar, not even a disabled one", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
||||
|
||||
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
|
||||
expect(domain.value).toBe("ads");
|
||||
expect(domain.disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
|
||||
|
||||
const form = domain.closest("form")!;
|
||||
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
for (const control of controls) {
|
||||
// A disabled form control is skipped by the browser's tab order, and RAC
|
||||
// pins its own trigger out of it as well. Nothing in the row may
|
||||
// reintroduce itself with a reachable tabindex.
|
||||
expect(control.hasAttribute("disabled")).toBe(true);
|
||||
const tabindex = control.getAttribute("tabindex");
|
||||
expect(tabindex === null || tabindex === "-1").toBe(true);
|
||||
}
|
||||
// The stream is unfiltered — the server sends every query — so a row of
|
||||
// controls here would promise filtering that is not happening. The filters
|
||||
// are not lost: they are in the URL, and History applies them on the way back.
|
||||
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
||||
expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
||||
expect(screen.queryByText("Clear")).toBeNull();
|
||||
expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
||||
@@ -431,31 +545,30 @@ test("live mode asks for no query pages, whatever filters the url retained", asy
|
||||
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "History" }));
|
||||
await screen.findByRole("button", { name: "Apply filters" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
await screen.findByLabelText("Filter domains");
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
// The filters came along, which is the point of switching rather than
|
||||
// navigating: the reader keeps the question they were asking.
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
||||
expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Live" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(sources[1]!.closed).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* The related-actions region of a query detail. Scoped on purpose: the sidebar
|
||||
* carries a Pause of its own, and this is the one that answers "this query was
|
||||
* blocked and should not have been".
|
||||
*/
|
||||
/** The related-actions region of a query detail. */
|
||||
function related(): HTMLElement {
|
||||
return screen.getByRole("region", { name: "Related" });
|
||||
}
|
||||
|
||||
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => {
|
||||
/**
|
||||
* The streamed detail carries the same Related as the persisted one: four links
|
||||
* and no control. Pause is resolver-wide and lives in the sidebar alone.
|
||||
*/
|
||||
test("a streamed blocked row's Related carries links only", async () => {
|
||||
await openLive();
|
||||
act(() =>
|
||||
sources[0]!.emit(
|
||||
@@ -468,14 +581,7 @@ test("a streamed blocked row carries the same Pause action as the persisted deta
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
|
||||
});
|
||||
|
||||
test("a streamed row that was allowed offers nothing to pause", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1001, "allowed.example", { policy: { action: "allow" } })));
|
||||
fireEvent.click(screen.getByRole("button", { name: "allowed.example" }));
|
||||
|
||||
await screen.findByRole("heading", { level: 1, name: "allowed.example" });
|
||||
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
||||
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
|
||||
expect(within(related()).getAllByRole("link")).toHaveLength(4);
|
||||
expect(within(related()).queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
|
||||
* in-place detail a streamed row opens.
|
||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
|
||||
* detail a streamed row opens.
|
||||
*
|
||||
* This subtree is mounted only while the URL says `mode=live`, which is what
|
||||
* closes the EventSource on the way back to history: the connection is a
|
||||
@@ -16,9 +16,11 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import { summarizeEvent } from "@/features/provenance/querySummary";
|
||||
import Dialog from "@/ui/Dialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
import ProvenanceDetail from "./ProvenanceDetail";
|
||||
import RelatedActions from "./RelatedActions";
|
||||
@@ -76,7 +78,7 @@ const styles = stylex.create({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
|
||||
@@ -88,6 +90,7 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
dismiss: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
@@ -104,7 +107,7 @@ const styles = stylex.create({
|
||||
},
|
||||
cappedBox: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.dangerBorder,
|
||||
@@ -128,7 +131,7 @@ const styles = stylex.create({
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -161,29 +164,6 @@ const styles = stylex.create({
|
||||
textDecorationLine: "underline",
|
||||
textDecorationStyle: "dotted",
|
||||
},
|
||||
detailPanel: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surface,
|
||||
padding: "1rem",
|
||||
},
|
||||
detailBar: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
footnote: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -192,10 +172,6 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
|
||||
const DETAIL_PANEL_ID = "live-query-detail";
|
||||
const DETAIL_LABEL_ID = "live-query-detail-label";
|
||||
|
||||
const PILL_LABELS: Record<StreamStatus, string> = {
|
||||
connecting: "Connecting…",
|
||||
open: "Live",
|
||||
@@ -225,40 +201,15 @@ function StatusPill({ status }: { status: StreamStatus }) {
|
||||
*
|
||||
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
||||
* key would go stale under the reader while they were still reading it, and the
|
||||
* panel would blank out for no reason they could see. The snapshot is the whole
|
||||
* fact — a streamed frame carries its own provenance — so it survives eviction,
|
||||
* a merge and a Freeze/Resume, and closes only when the reader closes it or
|
||||
* leaves live mode.
|
||||
* dialog would blank out or swap under their eyes for no reason they could see.
|
||||
* The snapshot is the whole fact — a streamed frame carries its own provenance
|
||||
* — so it survives eviction, a merge and a Freeze/Resume, and closes only when
|
||||
* the reader closes it or leaves live mode. The stream behind it never stops.
|
||||
*/
|
||||
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
||||
const summary = summarizeEvent(row.event);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The panel opens above the table, behind the trigger in tab order, so a
|
||||
// forward tab from the row would walk past it. Focus moves in on open —
|
||||
// keyed on the row, so choosing a second row moves it again — and the
|
||||
// closer puts it back on the trigger.
|
||||
useEffect(() => {
|
||||
panel.current?.focus();
|
||||
}, [row.key]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panel}
|
||||
id={DETAIL_PANEL_ID}
|
||||
tabIndex={-1}
|
||||
role="group"
|
||||
aria-labelledby={DETAIL_LABEL_ID}
|
||||
{...stylex.props(styles.detailPanel)}
|
||||
>
|
||||
<div {...stylex.props(styles.detailBar)}>
|
||||
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
|
||||
Streamed query
|
||||
</span>
|
||||
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
|
||||
<ProvenanceDetail
|
||||
provenance={row.event}
|
||||
persistedId={null}
|
||||
@@ -268,11 +219,10 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
|
||||
client={summary.client_ip}
|
||||
ts={summary.ts}
|
||||
origin={origin}
|
||||
blocked={row.event.policy.action === "block"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,21 +242,36 @@ export default function LiveActivity({
|
||||
const clientNames = useClientNames();
|
||||
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||
const results = useRef<HTMLDivElement>(null);
|
||||
const restoring = useRef(false);
|
||||
|
||||
/**
|
||||
* Where focus lands when the dialog closes.
|
||||
*
|
||||
* React Aria restores focus itself, but in a `requestAnimationFrame` and
|
||||
* only while focus is still on the body — and its target is the row button,
|
||||
* which the ring may have evicted while the reader was reading. So this runs
|
||||
* in the effect that follows the focus scope's teardown and puts focus on a
|
||||
* connected element first: the row if it is still there, the results region
|
||||
* if it is not. React Aria's deferred pass then finds focus already placed
|
||||
* and does nothing, so the two never fight over it. If a navigation unmounts
|
||||
* this component the effect never runs, which is the right answer — there is
|
||||
* no longer a table to return to.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (selected !== null || !restoring.current) return;
|
||||
restoring.current = false;
|
||||
const from = trigger.current;
|
||||
trigger.current = null;
|
||||
(from?.isConnected === true ? from : results.current)?.focus();
|
||||
}, [selected]);
|
||||
|
||||
function open(row: StreamedRow, from: HTMLButtonElement) {
|
||||
trigger.current = from;
|
||||
restoring.current = true;
|
||||
setSelected(row);
|
||||
}
|
||||
|
||||
// The row that opened the panel takes focus back, unless the ring has
|
||||
// already evicted it: a detached button cannot be focused, and the browser
|
||||
// falls back to the document, which is the best available answer.
|
||||
function close() {
|
||||
setSelected(null);
|
||||
trigger.current?.focus();
|
||||
trigger.current = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
@@ -323,7 +288,8 @@ export default function LiveActivity({
|
||||
|
||||
{live.frozen && (
|
||||
<p {...stylex.props(styles.note)} role="status">
|
||||
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept).
|
||||
Display frozen — new queries keep buffering ({formatCount(live.liveCount)} in buffer, newest{" "}
|
||||
{formatCount(capacity)} kept).
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -331,7 +297,10 @@ export default function LiveActivity({
|
||||
<div role="status" {...stylex.props(styles.resumed)}>
|
||||
<span>
|
||||
Stream resumed —{" "}
|
||||
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
|
||||
{live.missed === 0
|
||||
? "no queries missed"
|
||||
: `${formatCount(live.missed)} missed queries recovered`}
|
||||
.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -362,8 +331,18 @@ export default function LiveActivity({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
|
||||
|
||||
{/*
|
||||
* The region is the anchor focus falls back to when the row that
|
||||
* opened the dialog is gone, so it is rendered unconditionally: an
|
||||
* anchor that disappears with the last row is no anchor at all.
|
||||
*/}
|
||||
<div
|
||||
ref={results}
|
||||
tabIndex={-1}
|
||||
role="region"
|
||||
aria-label="Live queries"
|
||||
{...stylex.props(shared.focusRing)}
|
||||
>
|
||||
{live.rows.length === 0 ? (
|
||||
live.status !== "capped" && (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
@@ -372,7 +351,7 @@ export default function LiveActivity({
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
@@ -390,10 +369,7 @@ export default function LiveActivity({
|
||||
row.kind === "streamed" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={selected?.key === row.key}
|
||||
aria-controls={
|
||||
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
|
||||
}
|
||||
aria-haspopup="dialog"
|
||||
onClick={(event) => open(row, event.currentTarget)}
|
||||
{...stylex.props(
|
||||
styles.domainButton,
|
||||
@@ -422,11 +398,14 @@ export default function LiveActivity({
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footnote)}>
|
||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
||||
{capacity} kept).
|
||||
Showing {formatCount(live.rows.length)} {live.rows.length === 1 ? "query" : "queries"}{" "}
|
||||
(newest first, last {formatCount(capacity)} kept).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { Group, LookupResult } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
@@ -28,6 +29,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.5rem",
|
||||
@@ -63,7 +65,7 @@ const styles = stylex.create({
|
||||
},
|
||||
card: {
|
||||
marginTop: "1.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -177,7 +179,7 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
if (error.status === 429) {
|
||||
return error.retryAfter !== undefined
|
||||
? `Rate limited. Try again in ${error.retryAfter}s.`
|
||||
? `Rate limited. Try again in ${formatDuration(error.retryAfter)}.`
|
||||
: "Rate limited. Try again shortly.";
|
||||
}
|
||||
return error.message;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "@/features/provenance/provenanceCopy";
|
||||
import { qtypeName } from "@/features/provenance/qtype";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
@@ -40,7 +40,7 @@ const styles = stylex.create({
|
||||
record: {
|
||||
marginTop: "1rem",
|
||||
maxWidth: "48rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import PauseControl from "@/features/pause/PauseControl";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { provenanceRelatedLink } from "./ProvenanceDetail";
|
||||
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
|
||||
@@ -24,15 +23,9 @@ interface Props {
|
||||
ts: number;
|
||||
/** The Activity search the reader came from; its bounds win over the defaults. */
|
||||
origin: Pick<ActivitySearch, "since" | "until">;
|
||||
/**
|
||||
* This query was blocked. Pausing is a valid answer to a block the reader
|
||||
* disagrees with, and to nothing else here — so the control appears for a
|
||||
* block and not beside an allowed query it could not have caused.
|
||||
*/
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) {
|
||||
export default function RelatedActions({ domain, client, ts, origin }: Props) {
|
||||
const bounds = relatedBounds(ts, origin);
|
||||
const window = diagnosticsBounds(ts);
|
||||
return (
|
||||
@@ -61,7 +54,6 @@ export default function RelatedActions({ domain, client, ts, origin, blocked }:
|
||||
>
|
||||
Diagnostics around this query
|
||||
</Link>
|
||||
{blocked && <PauseControl />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,13 @@ import { rcodeShortName } from "@/features/provenance/provenanceCopy";
|
||||
import { qtypeName } from "@/features/provenance/qtype";
|
||||
import type { QuerySummary } from "@/features/provenance/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
/** Wide enough for an ordinary hostname, narrow enough to leave the six other columns room. */
|
||||
const DOMAIN_MAX_WIDTH = "24rem";
|
||||
|
||||
const styles = stylex.create({
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
@@ -46,6 +49,9 @@ const styles = stylex.create({
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
domainCell: {
|
||||
maxWidth: DOMAIN_MAX_WIDTH,
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
@@ -53,10 +59,32 @@ const styles = stylex.create({
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* The padding makes the whole row height clickable, and the equal negative
|
||||
* margin gives that height back to the row. The clamp lives on the inner
|
||||
* span, not here: a padded `-webkit-box` can paint a third clipped line
|
||||
* inside its own padding.
|
||||
*/
|
||||
domainLink: {
|
||||
display: "block",
|
||||
maxWidth: DOMAIN_MAX_WIDTH,
|
||||
paddingBlock: "0.5rem",
|
||||
marginBlock: "-0.5rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
/**
|
||||
* A domain is unbounded; the column is not. The clamp counts only the lines
|
||||
* of the element that directly holds the text, so every cell wraps its text
|
||||
* in this span — a `td` that took `display: -webkit-box` would stop being a
|
||||
* table cell.
|
||||
*/
|
||||
domainClamp: {
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
/**
|
||||
* The badge shape and its weight are the signal; the tint only says which
|
||||
* kind of unhappy answer this was. A monochrome or colour-blind reading of
|
||||
@@ -65,7 +93,7 @@ const styles = stylex.create({
|
||||
*/
|
||||
badge: {
|
||||
display: "inline-block",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
@@ -136,8 +164,11 @@ export function ActivityCells({
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{renderDomain(row.id, row.domain)}
|
||||
<td
|
||||
title={row.domain}
|
||||
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
|
||||
>
|
||||
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { validateActivitySearch, validateBlocked, validateMode, validateText, validateTimestamp } from "./search";
|
||||
import {
|
||||
validateActivitySearch,
|
||||
validateBlocked,
|
||||
validateMode,
|
||||
validateText,
|
||||
validateTimestamp,
|
||||
MAX_CLIENTS,
|
||||
validateClients,
|
||||
} from "./search";
|
||||
|
||||
test("mode is the two-value union, defaulting to history", () => {
|
||||
expect(validateMode("live")).toBe("live");
|
||||
@@ -111,3 +119,23 @@ test("a search of junk applies nothing", () => {
|
||||
blocked: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("the client list is canonicalized once, so the chips and the request agree", () => {
|
||||
expect(validateClients("192.0.2.10,192.0.2.11")).toBe("192.0.2.10,192.0.2.11");
|
||||
|
||||
// Blanks name no client and a repeat asks for the same client twice, so
|
||||
// neither changes which rows come back: dropping them is the same filter
|
||||
// written once, not a different one.
|
||||
expect(validateClients(" 192.0.2.10 , ,192.0.2.11,192.0.2.10,")).toBe("192.0.2.10,192.0.2.11");
|
||||
expect(validateClients(",,")).toBeUndefined();
|
||||
expect(validateClients("")).toBeUndefined();
|
||||
expect(validateClients(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a pasted list past the cap is cut to what the api will accept", () => {
|
||||
const addresses = Array.from({ length: MAX_CLIENTS + 8 }, (_, index) => `198.51.100.${index + 1}`);
|
||||
|
||||
// The API refuses a longer list outright, so keeping the extra addresses
|
||||
// would show a filter that cannot be applied at all.
|
||||
expect(validateClients(addresses.join(","))).toBe(addresses.slice(0, MAX_CLIENTS).join(","));
|
||||
});
|
||||
|
||||
@@ -57,6 +57,37 @@ export function validateText(value: unknown): string | undefined {
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/** `queries_repo.max_clients`: past this the API answers 400 rather than filter. */
|
||||
export const MAX_CLIENTS = 32;
|
||||
|
||||
/**
|
||||
* The client filter: a comma-separated list of exact addresses, canonicalized
|
||||
* here and nowhere else.
|
||||
*
|
||||
* This is the one place the value is read, so it is the one place it can be made
|
||||
* to mean exactly one thing. Everything downstream — the chips that show the
|
||||
* filter and the request that applies it — reads what this returns, so the two
|
||||
* cannot disagree about a link somebody pasted.
|
||||
*
|
||||
* The rule: entries are trimmed, blanks are dropped, repeats are dropped, and
|
||||
* the list is cut to the cap. A blank entry names no client and a repeat asks
|
||||
* for the same client twice, so neither changes which rows come back; dropping
|
||||
* them is not a different filter, it is the same filter written once. The cut is
|
||||
* a different filter, and it is the honest one available: the API refuses a
|
||||
* longer list outright, so keeping the extra addresses would show a filter that
|
||||
* cannot be applied at all.
|
||||
*/
|
||||
export function validateClients(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const seen = new Set<string>();
|
||||
for (const entry of value.split(",")) {
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed !== "") seen.add(trimmed);
|
||||
if (seen.size === MAX_CLIENTS) break;
|
||||
}
|
||||
return seen.size === 0 ? undefined : [...seen].join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* The API filter for a validated search, built field by field.
|
||||
*
|
||||
@@ -82,7 +113,7 @@ export function validateActivitySearch(search: Record<string, unknown>): Activit
|
||||
since: validateTimestamp(search["since"]),
|
||||
until: validateTimestamp(search["until"]),
|
||||
domain: validateText(search["domain"]),
|
||||
client: validateText(search["client"]),
|
||||
client: validateClients(search["client"]),
|
||||
blocked: validateBlocked(search["blocked"]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ test("an id the list does not contain renders the missing-client state (D9)", as
|
||||
|
||||
await screen.findByRole("heading", { name: "No such client" });
|
||||
expect(screen.getByText(/no client with id 99/i)).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "← All clients" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "All clients" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("policy links to the group that filters this client", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
@@ -6,6 +7,7 @@ import InlineError from "@/lib/InlineError";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { clientsQuery } from "@/lib/queries";
|
||||
import { useAuthority } from "@/features/configuration/authority";
|
||||
import Card from "@/ui/Card";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ClientDisplayName, provenanceOf } from "./clientIdentity";
|
||||
@@ -17,16 +19,23 @@ const nowInSeconds = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
const styles = stylex.create({
|
||||
back: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
backIcon: {
|
||||
display: "inline-flex",
|
||||
},
|
||||
heading: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
address: {
|
||||
marginTop: "0.25rem",
|
||||
@@ -35,12 +44,6 @@ const styles = stylex.create({
|
||||
panel: {
|
||||
marginTop: "1rem",
|
||||
maxWidth: "48rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: "1rem",
|
||||
},
|
||||
facts: {
|
||||
display: "grid",
|
||||
@@ -65,9 +68,10 @@ const styles = stylex.create({
|
||||
},
|
||||
sectionHeading: {
|
||||
marginTop: "1.5rem",
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
fontSize: "1.4rem",
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.015em",
|
||||
},
|
||||
prose: {
|
||||
marginTop: "0.5rem",
|
||||
@@ -86,7 +90,12 @@ const styles = stylex.create({
|
||||
link: {
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
/**
|
||||
* Room for the heading, the facts panel and the two sections under it, so
|
||||
* the page settles at roughly its filled height instead of growing into it.
|
||||
*/
|
||||
loading: {
|
||||
minHeight: "24rem",
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
@@ -95,7 +104,10 @@ const styles = stylex.create({
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link to="/clients" search={{}} {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← All clients
|
||||
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
|
||||
<ArrowLeft size={12} />
|
||||
</span>
|
||||
All clients
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +177,11 @@ export default function ClientDetailPage() {
|
||||
</h1>
|
||||
<p {...stylex.props(styles.address, shared.mono)}>{client.ip}</p>
|
||||
|
||||
<div {...stylex.props(styles.panel)}>
|
||||
<Card
|
||||
title="Details"
|
||||
description="The name nxdns shows for this device, where that name came from, and when the device was last seen."
|
||||
style={styles.panel}
|
||||
>
|
||||
<dl {...stylex.props(styles.facts)}>
|
||||
<dt {...stylex.props(styles.term)}>Name</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
@@ -181,7 +197,7 @@ export default function ClientDetailPage() {
|
||||
<dt {...stylex.props(styles.term)}>Last seen</dt>
|
||||
<dd {...stylex.props(styles.value)}>{formatTime(client.last_seen)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>Policy</h2>
|
||||
<p {...stylex.props(styles.prose)}>
|
||||
|
||||
@@ -18,16 +18,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
@@ -67,8 +61,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
|
||||
<Dialog title={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||
<form
|
||||
{...stylex.props(styles.form)}
|
||||
onSubmit={(event) => {
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { ClientName, type ClientNames } from "./clientNames";
|
||||
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||
import { BASE, CLIENTS, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function clientRow(ip: string): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr");
|
||||
@@ -73,6 +87,39 @@ test("the address links to the client's detail page", async () => {
|
||||
expect(link.getAttribute("href")).toBe("/clients/1");
|
||||
});
|
||||
|
||||
test("delete asks first, naming the row, and the confirmation carries out the delete", async () => {
|
||||
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
// The operator has to be able to tell from the dialog alone which row this is.
|
||||
expect(within(dialog).getByText(/kids-tablet\.lan \(192\.168\.1\.11\)/)).toBeTruthy();
|
||||
expect(within(dialog).getByText(/re-materialize on their next DNS query/)).toBeTruthy();
|
||||
// Asking is not deleting.
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2")),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
});
|
||||
|
||||
test("cancelling the confirmation keeps the client", async () => {
|
||||
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||
});
|
||||
|
||||
test("shows the DNS-activity empty state when there are no clients", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||
|
||||
@@ -140,9 +187,15 @@ test("an unknown group id filters to nothing and offers a way out", async () =>
|
||||
expect(router.state.location.search).toEqual({});
|
||||
});
|
||||
|
||||
// Both statuses lock the declared delete, but only file authority proves the
|
||||
// file declares the row; the anchors keep the two sentences apart.
|
||||
const DECLARED_NOTE = /^This client is declared in the configuration file/;
|
||||
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
|
||||
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
|
||||
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/);
|
||||
// The settled sentence, not the tag: "Locked" is already on screen while
|
||||
// authority is pending, so waiting on it would not wait for this status.
|
||||
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
|
||||
@@ -150,6 +203,34 @@ test("file mode drops every edit affordance and keeps the observed delete live (
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
// Why the locked Delete will not answer, in visible text and exactly once:
|
||||
// per row it would repeat down the whole page, and on the button it was a
|
||||
// title that a keyboard and a touch screen never reached.
|
||||
expect(screen.getAllByText(DECLARED_NOTE, { selector: "p" })).toHaveLength(1);
|
||||
expect(within(declared).queryByText(DECLARED_NOTE, { selector: "p" })).toBeNull();
|
||||
// The description stays on the button itself too, for a reader on that control.
|
||||
const locked = within(declared).getByRole("button", { name: "Delete" });
|
||||
expect(document.getElementById(locked.getAttribute("aria-describedby") ?? "")?.textContent).toMatch(DECLARED_NOTE);
|
||||
});
|
||||
|
||||
test("an all-observed page still says why Edit is gone, with no delete note to carry it", async () => {
|
||||
// Every row observed, so no Delete is locked. The edit lock is still real, and
|
||||
// "Locked" appearing with nothing to explain it is the failure this guards.
|
||||
const observedOnly = { clients: [CLIENTS.clients[1]] };
|
||||
await renderClientsPage({
|
||||
...BASE,
|
||||
"GET /api/clients": observedOnly,
|
||||
"GET /api/config/status": MANAGED_FILE,
|
||||
});
|
||||
// The settled sentence is both the anchor and the assertion: it is the whole
|
||||
// explanation for the missing Edit action.
|
||||
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
|
||||
expect(await screen.findAllByText("Locked")).not.toHaveLength(0);
|
||||
|
||||
// The delete sentence belongs only to a row that has one.
|
||||
expect(screen.queryByText(DECLARED_NOTE)).toBeNull();
|
||||
expect((screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
|
||||
@@ -170,7 +251,7 @@ test("file mode renders network assignments with no mutation control at all (R2-
|
||||
|
||||
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
|
||||
await screen.findAllByLabelText(/Configuration status unavailable/);
|
||||
await screen.findAllByText(/^Configuration status unavailable/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||
@@ -186,10 +267,6 @@ test("a failed config status exposes no configuration mutation, and still delete
|
||||
// confirmation is already open. `undefined` is the failed status: the fetch stub
|
||||
// answers 404 for a key it does not hold.
|
||||
//
|
||||
// Both statuses lock the declared delete, but only file authority proves the
|
||||
// file declares the row; the anchors keep the two sentences apart.
|
||||
const DECLARED_NOTE = /^This client is declared in the configuration file/;
|
||||
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
|
||||
describe.each([
|
||||
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
|
||||
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
|
||||
@@ -212,13 +289,13 @@ describe.each([
|
||||
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
|
||||
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
|
||||
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||
expect(within(dialog).getByText("Locked")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
test("locks an open declared-client delete confirmation and leaves cancel working", async () => {
|
||||
test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => {
|
||||
const map = { ...BASE };
|
||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
// The Edit affordance appearing is the proof authority resolved to
|
||||
@@ -227,23 +304,68 @@ describe.each([
|
||||
|
||||
const declared = clientRow("192.168.1.10");
|
||||
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
|
||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
|
||||
const confirming = clientRow("192.168.1.10");
|
||||
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
|
||||
expect(within(confirming).getByText(lockNote)).toBeTruthy();
|
||||
expect(within(confirming).queryByText(otherNote)).toBeNull();
|
||||
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||
// The dialog stays put. Closing it would throw focus at the Delete button
|
||||
// the same turn disabled, and the reason would survive only as a title
|
||||
// attribute; here the reason is the dialog's own message.
|
||||
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
|
||||
expect(within(dialog).getByText(lockNote)).toBeTruthy();
|
||||
expect(within(dialog).queryByText(otherNote)).toBeNull();
|
||||
expect(within(dialog).getByText("Locked")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||
});
|
||||
|
||||
test("a cancelled confirmation stays closed when authority comes back", async () => {
|
||||
const map = { ...BASE };
|
||||
const { queryClient } = await renderClientsPage(map);
|
||||
await unlockedEdit(0);
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
|
||||
// A poll that fails and then recovers must not raise a destructive
|
||||
// question the operator already answered.
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0));
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
});
|
||||
|
||||
test("the confirm action comes back when authority does, without a second prompt", async () => {
|
||||
const map = { ...BASE, "DELETE /api/clients/1": {} };
|
||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
await unlockedEdit(0);
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
|
||||
|
||||
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
|
||||
|
||||
// The question was never withdrawn, so the answer returns to the same
|
||||
// dialog rather than asking the operator to start again.
|
||||
const confirm = await within(dialog).findByRole("button", { name: "Delete" });
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
|
||||
const map = { ...BASE, "DELETE /api/clients/2": {} };
|
||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
@@ -253,10 +375,11 @@ describe.each([
|
||||
|
||||
const observed = clientRow("192.168.1.11");
|
||||
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
|
||||
await setConfigStatus(map, queryClient, status);
|
||||
|
||||
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
@@ -271,7 +394,7 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
|
||||
// The status request never settles, so authority stays pending for the whole
|
||||
// test: nothing configuration owns may be offered on that guess.
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
|
||||
await screen.findAllByLabelText(/Checking which configuration source/);
|
||||
await screen.findAllByText(/^Checking which configuration source/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||
@@ -280,3 +403,66 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("a save-time problem marks the input it is about and describes it", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||
|
||||
// Only the offending input is marked; the priority beside it is untouched.
|
||||
const priority = screen.getByLabelText("Priority for range 1");
|
||||
expect(priority.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(priority.getAttribute("aria-describedby")).toBeNull();
|
||||
|
||||
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||
fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe(
|
||||
"Row 1: priority must be a whole number.",
|
||||
);
|
||||
});
|
||||
|
||||
test("removing a row drops the message rather than moving it to another input", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
// Row 1 is the offending one, so removing it is what would slide the stale
|
||||
// index onto row 2 — an input that validated cleanly.
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
|
||||
fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||
|
||||
const section = assignmentsSection();
|
||||
fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement);
|
||||
|
||||
const survivor = screen.getByLabelText("Range 1") as HTMLInputElement;
|
||||
expect(survivor.value).toBe("10.0.0.0/8");
|
||||
expect(survivor.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(survivor.getAttribute("aria-describedby")).toBeNull();
|
||||
expect(within(section).queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByLabelText("Range 2")).toBeNull();
|
||||
});
|
||||
|
||||
test("editing a row clears the message it was about", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(within(assignmentsSection()).queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ import ClientEditDialog from "./ClientEditDialog";
|
||||
import NetworkAssignments from "./NetworkAssignments";
|
||||
import { ClientDisplayName } from "./clientIdentity";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import ConfigLockIndicator, { lockReason } from "@/features/configuration/ConfigLockIndicator";
|
||||
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -24,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
|
||||
* alone cannot say which operator surface set the row — so the sentence names
|
||||
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
|
||||
*/
|
||||
/** The one note every locked Delete on this page describes itself with. */
|
||||
const DELETE_LOCK_NOTE_ID = "clients-delete-locked-note";
|
||||
|
||||
function declaredDeleteNote(authority: Authority): string {
|
||||
if (authority.state === "resolved") {
|
||||
return "This client is declared in the configuration file; remove it there and restart.";
|
||||
@@ -31,16 +35,44 @@ function declaredDeleteNote(authority: Authority): string {
|
||||
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
|
||||
}
|
||||
|
||||
/**
|
||||
* How the confirmation names the row. The address is always there and always
|
||||
* unique, so it carries the sentence; a name the operator recognizes leads when
|
||||
* the row has one.
|
||||
*/
|
||||
function clientLabel(client: Client): string {
|
||||
const name = client.name !== "" ? client.name : client.learned_name;
|
||||
return name === "" ? client.ip : `${name} (${client.ip})`;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
/** A long reverse-DNS name would otherwise widen the column past the table. */
|
||||
name: {
|
||||
display: "block",
|
||||
maxWidth: "18rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
lockNote: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
filterBar: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
@@ -79,23 +111,11 @@ const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
confirmGroup: {
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
actionGroup: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
@@ -112,7 +132,7 @@ export default function ClientsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||
const [editing, setEditing] = useState<Client | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const authority = useAuthority();
|
||||
|
||||
@@ -122,6 +142,17 @@ export default function ClientsPage() {
|
||||
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
|
||||
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
|
||||
|
||||
// Authority is polled, so it can turn while the confirmation is open. The
|
||||
// dialog reads it on every render rather than trusting the state that opened
|
||||
// it, and withdraws the answer that would now fail instead of withdrawing the
|
||||
// question: the operator is told why, and only they close the dialog.
|
||||
const deleteLocked = pendingDelete !== null && readOnly && pendingDelete.hand_edited;
|
||||
|
||||
// The reason a locked Delete will not answer, printed once above the table.
|
||||
// Per row it would repeat down the whole page; on the button it was a `title`
|
||||
// that a keyboard and a touch screen never reached.
|
||||
const deletesLocked = readOnly && rows.some((client) => client.hand_edited);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||
@@ -137,6 +168,15 @@ export default function ClientsPage() {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{/* The whole explanation for this page's locks, printed once. Per row it
|
||||
would repeat down the table; on the controls it was a `title` that a
|
||||
keyboard and a touch screen never reached. */}
|
||||
{readOnly && (
|
||||
<div {...stylex.props(styles.lockNote)}>
|
||||
<p>{lockReason(authority)}.</p>
|
||||
{deletesLocked && <p id={DELETE_LOCK_NOTE_ID}>{declaredDeleteNote(authority)}</p>}
|
||||
</div>
|
||||
)}
|
||||
{clients.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||
@@ -145,7 +185,7 @@ export default function ClientsPage() {
|
||||
) : rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No clients match this filter.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
@@ -172,54 +212,19 @@ export default function ClientsPage() {
|
||||
</Link>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<span {...stylex.props(styles.name)}>
|
||||
<ClientDisplayName client={client} />
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.right)}>
|
||||
{confirmingId === client.id ? (
|
||||
<span {...stylex.props(styles.confirmGroup)}>
|
||||
{/* Authority is polled, so it can turn while a confirmation
|
||||
sits open. The confirm path reads it on every render
|
||||
rather than trusting the state that opened it. */}
|
||||
<span {...stylex.props(styles.note)}>
|
||||
{readOnly && client.hand_edited
|
||||
? declaredDeleteNote(authority)
|
||||
: "Deleted clients re-materialize on their next DNS query."}
|
||||
</span>
|
||||
{readOnly && client.hand_edited ? (
|
||||
<ConfigLockIndicator />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmingId(null);
|
||||
deleteMutation.mutate(client.id);
|
||||
}}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(null)}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
{/* Naming a client writes configuration, so the affordance is
|
||||
absent — not disabled — wherever the write cannot land. */}
|
||||
{readOnly ? (
|
||||
<ConfigLockIndicator />
|
||||
<ConfigLockIndicator compact />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -231,12 +236,10 @@ export default function ClientsPage() {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
onClick={() => setPendingDelete(client)}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? declaredDeleteNote(authority)
|
||||
: undefined
|
||||
aria-describedby={
|
||||
readOnly && client.hand_edited ? DELETE_LOCK_NOTE_ID : undefined
|
||||
}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
@@ -248,7 +251,6 @@ export default function ClientsPage() {
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -256,6 +258,24 @@ export default function ClientsPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete client"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: deleteLocked
|
||||
? declaredDeleteNote(authority)
|
||||
: `Delete ${clientLabel(pendingDelete)}? Deleted clients re-materialize on their next DNS query.`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
lock={deleteLocked ? <ConfigLockIndicator /> : undefined}
|
||||
onConfirm={() => {
|
||||
if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
<InlineError error={deleteMutation.error} />
|
||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
||||
|
||||
@@ -4,18 +4,30 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||
import type { ClientPrefix, Group } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import {
|
||||
firstProblem,
|
||||
initPrefixEditor,
|
||||
isDirty,
|
||||
prefixEditorReducer,
|
||||
toInputs,
|
||||
type PrefixEditorAction,
|
||||
type PrefixProblem,
|
||||
} from "./prefixEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import AuthorityGate from "@/features/configuration/AuthorityGate";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
interface Props {
|
||||
prefixes: ClientPrefix[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
/** The editor is rendered once per page, so the message can hold a fixed id. */
|
||||
const VALIDATION_ID = "network-assignments-validation";
|
||||
|
||||
const styles = stylex.create({
|
||||
section: {
|
||||
marginTop: "2.5rem",
|
||||
@@ -65,7 +77,8 @@ const styles = stylex.create({
|
||||
width: "5rem",
|
||||
},
|
||||
removeButton: {
|
||||
borderRadius: "0.25rem",
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -130,7 +143,7 @@ export default function NetworkAssignments({ prefixes, groups }: Props) {
|
||||
function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
||||
if (prefixes.length === 0) return <p {...stylex.props(styles.empty)}>The file declares no network assignments.</p>;
|
||||
return (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -161,12 +174,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
||||
function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<PrefixProblem | null>(null);
|
||||
const dirty = isDirty(state);
|
||||
const fallbackGroupId = defaultGroupId(groups);
|
||||
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
||||
|
||||
// A problem names a row by position, and every action here can move, add or
|
||||
// delete a position. The message describes the rows Save read, so it dies
|
||||
// with them rather than drifting onto whatever row inherits the index.
|
||||
const dispatch = (action: PrefixEditorAction) => {
|
||||
setValidation(null);
|
||||
apply(action);
|
||||
};
|
||||
|
||||
/** True for the one input the current message is about; nothing else is marked. */
|
||||
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
|
||||
validation !== null && validation.index === index && validation.field === field ? true : undefined;
|
||||
|
||||
const save = () => {
|
||||
const problem = firstProblem(state.rows);
|
||||
setValidation(problem);
|
||||
@@ -179,14 +204,16 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
return (
|
||||
<>
|
||||
{state.rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No network assignments configured.</p>
|
||||
<p {...stylex.props(styles.empty)}>No network assignments configured. Add one below.</p>
|
||||
) : (
|
||||
<ul {...stylex.props(styles.rows)}>
|
||||
{state.rows.map((row, index) => (
|
||||
<li key={index} {...stylex.props(styles.row)}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Range ${index + 1}`}
|
||||
aria-label={`Range ${formatCount(index + 1)}`}
|
||||
aria-invalid={invalid(index, "prefix")}
|
||||
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
|
||||
placeholder="192.168.1.0/24"
|
||||
value={row.prefix}
|
||||
onChange={(event) =>
|
||||
@@ -195,7 +222,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
|
||||
/>
|
||||
<Select
|
||||
aria-label={`Group for range ${index + 1}`}
|
||||
aria-label={`Group for range ${formatCount(index + 1)}`}
|
||||
variant="inline"
|
||||
value={String(row.group_id)}
|
||||
onChange={(value) =>
|
||||
@@ -206,7 +233,9 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={`Priority for range ${index + 1}`}
|
||||
aria-label={`Priority for range ${formatCount(index + 1)}`}
|
||||
aria-invalid={invalid(index, "priority")}
|
||||
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
|
||||
placeholder="100"
|
||||
value={row.priority}
|
||||
onChange={(event) =>
|
||||
@@ -226,8 +255,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
</ul>
|
||||
)}
|
||||
{validation !== null && (
|
||||
<p role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation}
|
||||
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation.message}
|
||||
</p>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
@@ -250,10 +279,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValidation(null);
|
||||
dispatch({ type: "reset", prefixes });
|
||||
}}
|
||||
onClick={() => dispatch({ type: "reset", prefixes })}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Discard changes
|
||||
|
||||
@@ -16,6 +16,14 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { clientsQuery } from "@/lib/queries";
|
||||
import type { Client } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
/** Secondary to the name it qualifies, and never the only thing in the cell. */
|
||||
address: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
|
||||
|
||||
@@ -53,11 +61,13 @@ export function clientLabel(ip: string, names: ClientNames): { text: string; lea
|
||||
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
|
||||
const label = clientLabel(ip, names);
|
||||
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
||||
// The name replaces the address on screen, so the address stays reachable
|
||||
// as the tooltip rather than disappearing from the row entirely.
|
||||
// The name replaces the address, so the address follows it as real text that
|
||||
// anyone can read and copy. A `title` carried it before, which reaches
|
||||
// neither a keyboard nor a touch screen.
|
||||
return (
|
||||
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
|
||||
<span {...stylex.props(label.learned && shared.learnedName)}>
|
||||
{label.text}
|
||||
<span {...stylex.props(styles.address)}> ({ip})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,11 +76,15 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
|
||||
|
||||
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
||||
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
|
||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toEqual({
|
||||
index: 0,
|
||||
field: "prefix",
|
||||
message: "Row 1: prefix is required.",
|
||||
});
|
||||
expect(
|
||||
firstProblem([
|
||||
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
||||
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
||||
]),
|
||||
).toBe("Row 2: priority must be a whole number.");
|
||||
).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatCount } from "@/lib/format";
|
||||
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
|
||||
|
||||
export interface PrefixRow {
|
||||
@@ -55,11 +56,24 @@ export function isDirty(state: PrefixEditorState): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export function firstProblem(rows: PrefixRow[]): string | null {
|
||||
for (const [i, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
|
||||
/** Which input the message is about, so the editor can point that input at it. */
|
||||
export interface PrefixProblem {
|
||||
index: number;
|
||||
field: "prefix" | "priority";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "")
|
||||
return { index, field: "prefix", message: `Row ${formatCount(index + 1)}: prefix is required.` };
|
||||
const priority = row.priority.trim();
|
||||
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
|
||||
if (priority !== "" && !/^\d+$/.test(priority))
|
||||
return {
|
||||
index,
|
||||
field: "priority",
|
||||
message: `Row ${formatCount(index + 1)}: priority must be a whole number.`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const PREFIXES = {
|
||||
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
|
||||
};
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
const VERSION = { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
export const DATABASE = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
|
||||
export const MANAGED_FILE = {
|
||||
|
||||
@@ -61,17 +61,20 @@ test("the lock is silent when the database owns the configuration", async () =>
|
||||
test("under file authority the lock names the file, in words a reader hears", async () => {
|
||||
renderIndicator(MANAGED_FILE);
|
||||
|
||||
// The reason is visible text beside the tag, not a title and not a label: a
|
||||
// tooltip reaches neither a keyboard nor a touch screen, and screen-reader-only
|
||||
// text is the same failure pointed the other way.
|
||||
const lock = await screen.findByText("Locked");
|
||||
await waitFor(() =>
|
||||
expect(lock.getAttribute("aria-label")).toBe(
|
||||
`Locked. Managed by ${CONFIG_PATH}; edit the file and restart nxdns.`,
|
||||
),
|
||||
expect(screen.getByText(`Managed by ${CONFIG_PATH}; edit the file and restart nxdns`)).toBeTruthy(),
|
||||
);
|
||||
expect(lock.getAttribute("title")).toBeNull();
|
||||
expect(lock.getAttribute("aria-label")).toBeNull();
|
||||
});
|
||||
|
||||
test("an unanswered status still locks, and says that is why", async () => {
|
||||
renderIndicator("failed");
|
||||
|
||||
const lock = await screen.findByText("Locked");
|
||||
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable"));
|
||||
await screen.findByText("Locked");
|
||||
await waitFor(() => expect(screen.getByText(/^Configuration status unavailable/)).toBeTruthy());
|
||||
});
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { useAuthority } from "./authority";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { useAuthority, type Authority } from "./authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
row: {
|
||||
display: "inline-flex",
|
||||
alignItems: "baseline",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
/** The sentence is secondary to the word, and wraps rather than stretching a row. */
|
||||
reason: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tag: {
|
||||
marginLeft: "0.5rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
@@ -26,20 +38,34 @@ const styles = stylex.create({
|
||||
* The word is real text, not colour or an icon, so a screen reader announces
|
||||
* the reason the control will not answer.
|
||||
*/
|
||||
export default function ConfigLockIndicator() {
|
||||
/**
|
||||
* Why a configuration control will not answer. Exported because a page that
|
||||
* shows the compact tag has to print this sentence itself, once, somewhere the
|
||||
* tag can point at.
|
||||
*/
|
||||
export function lockReason(authority: Authority): string {
|
||||
if (authority.state === "pending") return "Checking which configuration source this server obeys";
|
||||
if (authority.state === "failed") return "Configuration status unavailable, so edits are held back";
|
||||
return `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
|
||||
}
|
||||
|
||||
export default function ConfigLockIndicator({ compact = false }: { compact?: boolean }) {
|
||||
const authority = useAuthority();
|
||||
if (authority.state === "resolved" && authority.status.authority === "database") return null;
|
||||
|
||||
const reason =
|
||||
authority.state === "pending"
|
||||
? "Checking which configuration source this server obeys"
|
||||
: authority.state === "failed"
|
||||
? "Configuration status unavailable, so edits are held back"
|
||||
: `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
|
||||
const reason = lockReason(authority);
|
||||
|
||||
// A compact caller has no room for the sentence and must print it once
|
||||
// nearby instead: the Clients table would otherwise repeat it down every row.
|
||||
if (compact) return <span {...stylex.props(styles.tag)}>Locked</span>;
|
||||
|
||||
// Everywhere else the reason is visible text rather than a `title` or an
|
||||
// `aria-label`. A tooltip reaches neither a keyboard nor a touch screen, and
|
||||
// screen-reader-only text is the same failure pointed the other way.
|
||||
return (
|
||||
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}>
|
||||
Locked
|
||||
<span {...stylex.props(styles.row)}>
|
||||
<span {...stylex.props(styles.tag)}>Locked</span>
|
||||
<span {...stylex.props(styles.reason)}>{reason}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||
import type { Blocklist } from "@/lib/types";
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
import { sameSet } from "./sourceSet";
|
||||
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -20,6 +21,13 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* Room for a few checkboxes and the button row, so the sections below the
|
||||
* panel do not jump up the page when the assignment lands.
|
||||
*/
|
||||
loading: {
|
||||
minHeight: "8rem",
|
||||
},
|
||||
root: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
@@ -28,13 +36,6 @@ const styles = stylex.create({
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
buttonRow: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
@@ -50,7 +51,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
|
||||
if (sources.isPending) {
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.note)}>
|
||||
<p role="status" {...stylex.props(styles.note, styles.loading)}>
|
||||
Loading sources…
|
||||
</p>
|
||||
);
|
||||
@@ -66,21 +67,22 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.root)}>
|
||||
{/* The section's own "Assigned sources" heading is the visible label; a
|
||||
Label here would put the same words on screen twice. Ids cross the
|
||||
React Aria boundary as strings, the same convention as Select. */}
|
||||
<CheckboxGroup
|
||||
aria-label="Assigned sources"
|
||||
value={current.map(String)}
|
||||
onChange={(values) => setSelected(values.map(Number).sort((a, b) => a - b))}
|
||||
>
|
||||
<ul {...stylex.props(styles.list)}>
|
||||
{blocklists.map((blocklist) => (
|
||||
<li key={blocklist.id}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={current.includes(blocklist.id)}
|
||||
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
{blocklist.name}
|
||||
</label>
|
||||
<Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CheckboxGroup>
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
|
||||
@@ -15,10 +15,13 @@ export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
|
||||
Local records
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>local_records</code>
|
||||
</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
Names this resolver answers by itself, without asking an upstream.
|
||||
</p>
|
||||
{records.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>The file declares no local records.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -52,10 +55,13 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
|
||||
Forward zones
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>forward_zones</code>
|
||||
</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
Domains whose queries go to a resolver of their own instead of the upstream pool.
|
||||
</p>
|
||||
{zones.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>The file declares no forward zones.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import {
|
||||
blocklistsQuery,
|
||||
@@ -21,6 +21,7 @@ import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import DefinitionList from "@/ui/DefinitionList";
|
||||
import Select from "@/ui/Select";
|
||||
import Switch from "@/ui/Switch";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import AuthorityGate from "./AuthorityGate";
|
||||
@@ -68,13 +69,6 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
spacer: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
@@ -173,7 +167,9 @@ function GroupsMasterDetail({ groups, status }: { groups: Group[]; status: Confi
|
||||
</nav>
|
||||
</div>
|
||||
{selected === undefined ? (
|
||||
<p {...stylex.props(config.empty)}>No groups exist.</p>
|
||||
<p {...stylex.props(config.empty)}>
|
||||
{fileMode ? "No groups exist." : "No groups exist. Create one with the New group field."}
|
||||
</p>
|
||||
) : fileMode ? (
|
||||
<GroupDetailReadOnly group={selected} />
|
||||
) : (
|
||||
@@ -229,7 +225,7 @@ function ClientCountLink({ group }: { group: Group }) {
|
||||
<Link to="/clients" search={{ group: group.id }} {...stylex.props(shared.focusRing)}>
|
||||
{count === undefined
|
||||
? "Clients in this group"
|
||||
: `${count} client${count === 1 ? "" : "s"} in this group`}
|
||||
: `${formatCount(count)} client${count === 1 ? "" : "s"} in this group`}
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
@@ -240,6 +236,10 @@ function GroupDetailReadOnly({ group }: { group: Group }) {
|
||||
<div>
|
||||
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
|
||||
<section {...stylex.props(config.panel)}>
|
||||
<h3 {...stylex.props(config.panelHeading)}>Settings</h3>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
The group's name and whether its clients get safe search.
|
||||
</p>
|
||||
<DefinitionList
|
||||
items={[
|
||||
{ label: "Name", zonKey: "groups[].name", value: group.name },
|
||||
@@ -263,6 +263,7 @@ function GroupSourcesReadOnly({ group }: { group: Group }) {
|
||||
Assigned sources
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>group_sources</code>
|
||||
</h3>
|
||||
<p {...stylex.props(config.panelDescription)}>The blocklists that apply to clients in this group.</p>
|
||||
<QueryPanel query={assigned}>
|
||||
{(sourceIds) => (
|
||||
<QueryPanel query={blocklists}>
|
||||
@@ -278,7 +279,7 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo
|
||||
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
|
||||
if (assigned.length === 0) return <p {...stylex.props(config.empty)}>This group is assigned no sources.</p>;
|
||||
return (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -355,21 +356,15 @@ function GroupDetailEditable({ group }: { group: Group }) {
|
||||
)}
|
||||
|
||||
<div {...stylex.props(styles.controlRow)}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={group.safe_search}
|
||||
disabled={update.isPending}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
onChange={(event) =>
|
||||
update.mutate({
|
||||
id: group.id,
|
||||
input: { name: group.name, safe_search: event.target.checked },
|
||||
})
|
||||
<Switch
|
||||
isSelected={group.safe_search}
|
||||
isDisabled={update.isPending}
|
||||
onChange={(safeSearch) =>
|
||||
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
|
||||
}
|
||||
/>
|
||||
>
|
||||
Safe search
|
||||
</label>
|
||||
</Switch>
|
||||
<span {...stylex.props(styles.spacer)}>
|
||||
{!renaming && (
|
||||
<button
|
||||
@@ -401,6 +396,7 @@ function GroupDetailEditable({ group }: { group: Group }) {
|
||||
|
||||
<section {...stylex.props(config.panel)}>
|
||||
<h3 {...stylex.props(config.panelHeading)}>Assigned sources</h3>
|
||||
<p {...stylex.props(config.panelDescription)}>The blocklists that apply to clients in this group.</p>
|
||||
<QueryPanel query={blocklists}>
|
||||
{(catalogue) => <GroupSourcesEditor groupId={group.id} blocklists={catalogue} />}
|
||||
</QueryPanel>
|
||||
@@ -438,13 +434,19 @@ function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
|
||||
Rules
|
||||
{!editable && <code {...stylex.props(shared.mono, config.panelKey)}>rules</code>}
|
||||
</h3>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
This group's own block and allow patterns, applied before any list.
|
||||
</p>
|
||||
<QueryPanel query={rules}>
|
||||
{(all) => {
|
||||
const scoped = all.filter((rule) => rule.group_id === group.id);
|
||||
return (
|
||||
<>
|
||||
{scoped.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>No allow or block rules for this group.</p>
|
||||
<p {...stylex.props(config.empty)}>
|
||||
No allow or block rules for this group.
|
||||
{editable && " Add one below."}
|
||||
</p>
|
||||
) : (
|
||||
<RulesTable rules={scoped} editable={editable} />
|
||||
)}
|
||||
@@ -464,7 +466,7 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -46,7 +46,7 @@ test("another group can be renamed and deleted, and carries its safe-search stat
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
@@ -60,7 +60,7 @@ test("toggling safe search resends the whole group row", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));
|
||||
|
||||
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||
expect(writes("PUT")[0]).toMatchObject({
|
||||
@@ -89,6 +89,31 @@ test("the source assignment saves the full set via PUT", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("the source checkboxes form one named group, and each carries its own state", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
// The section heading names the group; the boxes belong to it rather than
|
||||
// sitting loose beside the group's other checkboxes.
|
||||
const group = await screen.findByRole("group", { name: "Assigned sources" });
|
||||
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
|
||||
expect(ads.checked).toBe(false);
|
||||
// Safe search is the group's own field, not one of its sources, and it is a
|
||||
// switch rather than a checkbox because it applies the moment it moves.
|
||||
expect(within(group).queryByRole("switch")).toBeNull();
|
||||
expect(screen.getByRole("switch", { name: "Safe search" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(ads);
|
||||
await waitFor(() => expect(ads.checked).toBe(true));
|
||||
|
||||
// Discard returns the group to the server's set rather than clearing it.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||
await waitFor(() =>
|
||||
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
|
||||
);
|
||||
expect(writes("PUT")).toEqual([]);
|
||||
});
|
||||
|
||||
test("only the selected group's rules are listed", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
@@ -212,11 +237,33 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
|
||||
expect(
|
||||
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
||||
).toBeTruthy();
|
||||
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
|
||||
// Switches, not checkboxes: the row applies the moment it moves, and the role
|
||||
// is what tells a screen reader so.
|
||||
expect((screen.getByRole("switch", { name: "Ads enabled" }) as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByRole("switch", { name: "Trackers enabled" }) as HTMLInputElement).checked).toBe(false);
|
||||
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a source switch resends the whole row immediately, with no Save step", async () => {
|
||||
calls = stubApi(DATABASE);
|
||||
await renderPage("/configuration/protection?tab=sources", "Protection");
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch", { name: "Trackers enabled" }));
|
||||
|
||||
// No Save button stands between the switch and the write: one click, one PUT.
|
||||
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||
expect(writes("PUT")[0]).toMatchObject({
|
||||
url: "/api/blocklists/2",
|
||||
// The whole row goes back, not a patch of the one field that moved.
|
||||
body: {
|
||||
url: "https://example.com/trackers.txt",
|
||||
name: "Trackers",
|
||||
enabled: true,
|
||||
is_suggested: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Update now says it started, and says nothing once it succeeds", async () => {
|
||||
let release: ((response: Response) => void) | null = null;
|
||||
calls = stubApi(DATABASE, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import {
|
||||
blocklistCreateMutation,
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import Switch from "@/ui/Switch";
|
||||
import AuthorityGate from "./AuthorityGate";
|
||||
import BlocklistForm from "./BlocklistForm";
|
||||
import FileModeNote from "./FileModeNote";
|
||||
@@ -33,7 +34,7 @@ const styles = stylex.create({
|
||||
},
|
||||
badge: {
|
||||
marginLeft: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
backgroundColor: colors.border,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
@@ -130,10 +131,13 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
Blocklist sources
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>blocklist_sources</code>
|
||||
</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
The lists nxdns downloads, and what each one contributed at its last refresh.
|
||||
</p>
|
||||
{blocklists.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>The file declares no blocklist sources.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -157,12 +161,20 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.mono)}>{b.url}</td>
|
||||
<td {...stylex.props(shared.td)}>{String(b.enabled)}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{b.skipped_unsupported_count}
|
||||
{formatCount(b.domain_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.wildcard_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.exception_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.skipped_regex_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.skipped_unsupported_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
@@ -218,7 +230,7 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
{blocklists.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>No blocklist sources yet. Add one below.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -249,21 +261,27 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Switch
|
||||
aria-label={`${b.name} enabled`}
|
||||
checked={b.enabled}
|
||||
disabled={toggle.isPending}
|
||||
isSelected={b.enabled}
|
||||
isDisabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(b)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{b.skipped_unsupported_count}
|
||||
{formatCount(b.domain_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.wildcard_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.exception_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.skipped_regex_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{formatCount(b.skipped_unsupported_count)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
|
||||
@@ -3,7 +3,17 @@ import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { styles } from "./styles";
|
||||
import { styles as config } from "./styles";
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* Room for a panel heading and the first rows of the collection, so the
|
||||
* content below a panel does not jump up the page when its data lands.
|
||||
*/
|
||||
pending: {
|
||||
minHeight: "6rem",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* One panel's data, with the loading and error surfaces the fire-and-forget
|
||||
@@ -20,7 +30,7 @@ export default function QueryPanel<T>({
|
||||
}) {
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.pending, shared.pulse)}>
|
||||
<p role="status" {...stylex.props(config.pending, styles.pending, shared.pulse)}>
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function RecordsTab() {
|
||||
)}
|
||||
<QueryPanel query={query}>
|
||||
{(records) => (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
|
||||
@@ -37,7 +37,7 @@ function renderValue(value: unknown) {
|
||||
export default function SettingsDefinitions({ settings }: { settings: Settings }) {
|
||||
return (
|
||||
<>
|
||||
{SECTIONS.map(({ section, title, fields }) => {
|
||||
{SECTIONS.map(({ section, title, description, fields }) => {
|
||||
const values = sectionValues(settings, section);
|
||||
const items: Definition[] = (fields as readonly AnyFieldDef[]).map((def) => ({
|
||||
label: humanize(def.key),
|
||||
@@ -56,6 +56,7 @@ export default function SettingsDefinitions({ settings }: { settings: Settings }
|
||||
return (
|
||||
<section key={section} {...stylex.props(config.panel)}>
|
||||
<h2 {...stylex.props(config.panelHeading)}>{title}</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>{description}</p>
|
||||
<div {...stylex.props(config.note)}>
|
||||
<DefinitionList items={items} />
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,18 @@ import InlineError from "@/lib/InlineError";
|
||||
import { settingsPutMutation } from "@/lib/queries";
|
||||
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||
import type { Settings, SettingsEnvelope } from "@/lib/types";
|
||||
import { cardStyles } from "@/ui/Card";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { SECTIONS, sectionValues, type AnyFieldDef } from "./settingsSections";
|
||||
import { styles as config } from "./styles";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
/** The form is rendered once per page, so the message can hold a fixed id. */
|
||||
const MISMATCH_ID = "web.password_mismatch";
|
||||
|
||||
const styles = stylex.create({
|
||||
form: {
|
||||
marginTop: "1rem",
|
||||
@@ -27,18 +31,25 @@ const styles = stylex.create({
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
},
|
||||
/** A `fieldset` shrinks to its content by default, which would undo the card's own `minWidth: 0`. */
|
||||
section: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
padding: "1rem",
|
||||
minInlineSize: 0,
|
||||
},
|
||||
/**
|
||||
* A `legend` is otherwise drawn through the fieldset's top border, which cuts
|
||||
* the hairline and lifts the title away from its description. A floated
|
||||
* legend is not the fieldset's rendered legend (HTML rendering, "The fieldset
|
||||
* and legend elements"), so it lays out as ordinary content inside the
|
||||
* padding; the full width, and the `clear` on the description, keep the two
|
||||
* stacked.
|
||||
*/
|
||||
legend: {
|
||||
paddingInline: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
float: "left",
|
||||
width: "100%",
|
||||
padding: 0,
|
||||
},
|
||||
description: {
|
||||
clear: "both",
|
||||
},
|
||||
/** One column on a phone, two from `sm`. */
|
||||
fieldGrid: {
|
||||
@@ -68,7 +79,7 @@ const styles = stylex.create({
|
||||
gap: "0.25rem",
|
||||
},
|
||||
fieldInput: {
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -100,8 +111,9 @@ const styles = stylex.create({
|
||||
gap: "0.75rem",
|
||||
},
|
||||
save: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -174,12 +186,15 @@ function FieldRow({
|
||||
}
|
||||
if (def.kind === "number") {
|
||||
const numeric = value as number;
|
||||
// An empty or unparseable number reads back as NaN. The form already refuses
|
||||
// to submit on it; this is what says so to a screen reader.
|
||||
return (
|
||||
<div {...stylex.props(styles.field)}>
|
||||
<FieldLabel id={id} text={def.key} restart={restart} />
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
aria-invalid={Number.isNaN(numeric) || undefined}
|
||||
value={Number.isNaN(numeric) ? "" : numeric}
|
||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -254,9 +269,10 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
return (
|
||||
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||
<fieldset disabled={mutation.isPending} {...stylex.props(styles.sections)}>
|
||||
{SECTIONS.map(({ section, title, fields }) => (
|
||||
<fieldset key={section} {...stylex.props(styles.section)}>
|
||||
<legend {...stylex.props(styles.legend)}>{title}</legend>
|
||||
{SECTIONS.map(({ section, title, description, fields }) => (
|
||||
<fieldset key={section} {...stylex.props(cardStyles.card, styles.section)}>
|
||||
<legend {...stylex.props(cardStyles.title, styles.legend)}>{title}</legend>
|
||||
<p {...stylex.props(config.panelDescription, styles.description)}>{description}</p>
|
||||
<div {...stylex.props(styles.fieldGrid)}>
|
||||
{(fields as readonly AnyFieldDef[]).map((def) => (
|
||||
<FieldRow
|
||||
@@ -285,6 +301,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
id="web.password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={passwordsMismatch || undefined}
|
||||
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -298,6 +316,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
id="web.password_confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={passwordsMismatch || undefined}
|
||||
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -310,7 +330,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
</p>
|
||||
)}
|
||||
{passwordsMismatch && (
|
||||
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||
Passwords do not match.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -176,12 +176,52 @@ test("enum and boolean fields diff as their own types", async () => {
|
||||
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
||||
});
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
test("clearing a number field disables Save instead of sending NaN", async () => {
|
||||
await openSystem();
|
||||
|
||||
const cache = screen.getByRole("group", { name: "Cache" });
|
||||
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
|
||||
const size = within(cache).getByLabelText("size");
|
||||
fireEvent.change(size, { target: { value: "" } });
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
// The refusal is on the field itself, not only on the Save button.
|
||||
expect(size.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
fireEvent.change(size, { target: { value: "512" } });
|
||||
expect(size.getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
|
||||
test("the mismatch message is attached to both password inputs", async () => {
|
||||
await openSystem();
|
||||
|
||||
const web = screen.getByRole("group", { name: "Web" });
|
||||
const passwordInput = within(web).getByLabelText("password");
|
||||
const confirmInput = within(web).getByLabelText("confirm password");
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
|
||||
for (const input of [passwordInput, confirmInput]) {
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(input)).toBe("Passwords do not match.");
|
||||
}
|
||||
|
||||
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
|
||||
for (const input of [passwordInput, confirmInput]) {
|
||||
expect(input.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(input.getAttribute("aria-describedby")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import Switch from "@/ui/Switch";
|
||||
import AuthorityGate from "./AuthorityGate";
|
||||
import FileModeNote from "./FileModeNote";
|
||||
import QueryPanel from "./QueryPanel";
|
||||
@@ -66,10 +67,13 @@ function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
|
||||
Upstream pool
|
||||
<code {...stylex.props(shared.mono, config.panelKey)}>upstreams</code>
|
||||
</h2>
|
||||
<p {...stylex.props(config.panelDescription)}>
|
||||
The resolvers nxdns forwards to when neither a local record nor the cache has the answer.
|
||||
</p>
|
||||
{upstreams.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>The file declares no upstreams.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -142,7 +146,7 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
||||
{upstreams.length === 0 ? (
|
||||
<p {...stylex.props(config.empty)}>No upstreams yet. Add one below.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(config.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -165,13 +169,11 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Switch
|
||||
aria-label={`${u.url} enabled`}
|
||||
checked={u.enabled}
|
||||
disabled={toggle.isPending}
|
||||
isSelected={u.enabled}
|
||||
isDisabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(u)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function ZonesTab() {
|
||||
)}
|
||||
<QueryPanel query={query}>
|
||||
{(zones) => (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(shared.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
|
||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
||||
function mutationControls(): Element[] {
|
||||
return [
|
||||
...contentArea().querySelectorAll(
|
||||
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
|
||||
'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -99,14 +99,17 @@ test("a failed status is announced by the shell on a page that is not configurat
|
||||
stubApi(DATABASE, {
|
||||
responses: {
|
||||
"GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }),
|
||||
"GET /api/stats?period=24h": {
|
||||
"GET /api/overview?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
queries: 0,
|
||||
blocked: 0,
|
||||
clients: 0,
|
||||
avg_response_time_us: null,
|
||||
bucket_seconds: 1800,
|
||||
totals: { queries: 0, blocked: 0, clients: 0, avg_response_time_us: null },
|
||||
buckets: [],
|
||||
clients: [],
|
||||
other: [],
|
||||
types: [],
|
||||
routes: [],
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface FieldDef<S extends keyof Settings> {
|
||||
export interface SectionDef<S extends keyof Settings> {
|
||||
section: S;
|
||||
title: string;
|
||||
/** The card head's one line: what the section governs, in the reader's words. */
|
||||
description: string;
|
||||
fields: readonly FieldDef<S>[];
|
||||
}
|
||||
|
||||
@@ -47,6 +49,8 @@ export const SECTIONS: readonly AnySectionDef[] = [
|
||||
defineSection({
|
||||
section: "upstream",
|
||||
title: "Upstream",
|
||||
description:
|
||||
"How long nxdns waits on the upstream pool, per attempt and in total, and on a forward zone's resolver per read.",
|
||||
fields: [
|
||||
{ key: "attempt_timeout_ms", kind: "number" },
|
||||
{ key: "read_timeout_ms", kind: "number" },
|
||||
@@ -56,6 +60,8 @@ export const SECTIONS: readonly AnySectionDef[] = [
|
||||
defineSection({
|
||||
section: "dns",
|
||||
title: "DNS",
|
||||
description:
|
||||
"The addresses and port the resolver listens on, and how many queries one client may send within the rate window.",
|
||||
fields: [
|
||||
{ key: "bind_ipv4", kind: "text" },
|
||||
{ key: "bind_ipv6", kind: "text" },
|
||||
@@ -67,6 +73,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
|
||||
defineSection({
|
||||
section: "blocking",
|
||||
title: "Blocking",
|
||||
description: "What a blocked query is answered with, and for how long clients may keep that answer.",
|
||||
fields: [
|
||||
{ key: "response", kind: ["zero", "nxdomain"] },
|
||||
{ key: "ttl", kind: "number" },
|
||||
@@ -75,6 +82,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
|
||||
defineSection({
|
||||
section: "cache",
|
||||
title: "Cache",
|
||||
description: "How many answers are kept, and how long a negative answer stays valid.",
|
||||
fields: [
|
||||
{ key: "size", kind: "number" },
|
||||
{ key: "negative_ttl_max", kind: "number" },
|
||||
@@ -83,6 +91,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
|
||||
defineSection({
|
||||
section: "web",
|
||||
title: "Web",
|
||||
description: "Where this admin interface listens, how long a login lasts, and its request limits.",
|
||||
fields: [
|
||||
{ key: "enabled", kind: "boolean" },
|
||||
{ key: "bind", kind: "text" },
|
||||
@@ -94,12 +103,29 @@ export const SECTIONS: readonly AnySectionDef[] = [
|
||||
{ key: "trusted_proxies", kind: "text" },
|
||||
],
|
||||
}),
|
||||
defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }),
|
||||
defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }),
|
||||
defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }),
|
||||
defineSection({
|
||||
section: "doh_server",
|
||||
title: "DoH Server",
|
||||
description: "DNS over HTTPS for clients that speak it: the listener and its certificate.",
|
||||
fields: TLS_FIELDS,
|
||||
}),
|
||||
defineSection({
|
||||
section: "dot_server",
|
||||
title: "DoT Server",
|
||||
description: "DNS over TLS for clients that speak it: the listener and its certificate.",
|
||||
fields: TLS_FIELDS,
|
||||
}),
|
||||
defineSection({
|
||||
section: "edns",
|
||||
title: "EDNS",
|
||||
description: "Whether the client's subnet is passed on to upstreams or stripped from the query.",
|
||||
fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }],
|
||||
}),
|
||||
defineSection({
|
||||
section: "logging",
|
||||
title: "Logging",
|
||||
description:
|
||||
"What the process log records and where it goes; how query history is buffered, flushed and kept, and which of its fields are hidden.",
|
||||
fields: [
|
||||
{ key: "level", kind: ["error", "warn", "info", "debug"] },
|
||||
{ key: "retention_days", kind: "number" },
|
||||
@@ -116,6 +142,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
|
||||
defineSection({
|
||||
section: "disk",
|
||||
title: "Disk",
|
||||
description: "The free space below which nxdns warns, and below which it stops writing history.",
|
||||
fields: [
|
||||
{ key: "min_free_mb", kind: "number" },
|
||||
{ key: "warn_free_mb", kind: "number" },
|
||||
@@ -124,6 +151,7 @@ export const SECTIONS: readonly AnySectionDef[] = [
|
||||
defineSection({
|
||||
section: "blocklist_update",
|
||||
title: "Blocklist Update",
|
||||
description: "Whether the blocklists are fetched again on their own, and how often.",
|
||||
fields: [
|
||||
{ key: "enabled", kind: "boolean" },
|
||||
{ key: "interval_hours", kind: "number" },
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
|
||||
test("toggleSource adds a missing id keeping ascending order", () => {
|
||||
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
|
||||
expect(toggleSource([], 5)).toEqual([5]);
|
||||
});
|
||||
|
||||
test("toggleSource removes a present id", () => {
|
||||
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
|
||||
expect(toggleSource([5], 5)).toEqual([]);
|
||||
});
|
||||
|
||||
test("toggleSource twice is a no-op set-wise", () => {
|
||||
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
|
||||
});
|
||||
import { sameSet } from "./sourceSet";
|
||||
|
||||
test("sameSet compares regardless of order", () => {
|
||||
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export function toggleSource(ids: number[], id: number): number[] {
|
||||
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
|
||||
return [...ids, id].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function sameSet(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort((x, y) => x - y);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
export const styles = stylex.create({
|
||||
heading: {
|
||||
@@ -20,18 +20,32 @@ export const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** The card chrome (`ui/Card.tsx`), on panels whose heading carries a key or a control the Card head cannot. */
|
||||
panel: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
padding: "1rem",
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: metrics.cardPadding,
|
||||
},
|
||||
panelHeading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 500,
|
||||
margin: 0,
|
||||
fontSize: "1.4rem",
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.015em",
|
||||
},
|
||||
/** The card head's one-line description (`ui/Card.tsx`), under a panel heading. */
|
||||
panelDescription: {
|
||||
margin: 0,
|
||||
marginTop: "0.25rem",
|
||||
marginBottom: "1.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
textWrap: "pretty",
|
||||
},
|
||||
/** The collection's own key in the configuration file, beside its heading. */
|
||||
panelKey: {
|
||||
@@ -50,7 +64,7 @@ export const styles = stylex.create({
|
||||
/** The file-mode page note: where edits happen, and what applies them. */
|
||||
fileNote: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -90,7 +104,7 @@ export const styles = stylex.create({
|
||||
},
|
||||
masterLink: {
|
||||
display: "block",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -119,7 +133,7 @@ export const styles = stylex.create({
|
||||
/** The authority error state: no forms, no definition list, one way forward. */
|
||||
blocked: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.warnBorder,
|
||||
|
||||
@@ -194,7 +194,7 @@ function defaultResponses(status: ConfigStatus): Record<string, unknown> {
|
||||
return {
|
||||
"GET /api/config/status": status,
|
||||
"GET /api/health": health(),
|
||||
"GET /api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
"GET /api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
"GET /api/groups": { groups: GROUPS },
|
||||
"GET /api/groups/1/sources": { source_ids: [1] },
|
||||
"GET /api/groups/2/sources": { source_ids: [] },
|
||||
|
||||
@@ -35,7 +35,7 @@ let requested: string[];
|
||||
beforeEach(() => {
|
||||
requested = [];
|
||||
responses = {
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
"/api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
// The shell reads health for the Diagnostics nav badge on every route.
|
||||
"/api/health": health(),
|
||||
};
|
||||
@@ -93,7 +93,8 @@ test("an open episode shows its facts, its copy and the error the server sent",
|
||||
await screen.findByRole("heading", { name: "Blocklist source failed to update" });
|
||||
expect(screen.getByText("Warning")).toBeTruthy();
|
||||
expect(screen.getByText("StevenBlack")).toBeTruthy();
|
||||
expect(screen.getByText("Active for 2h")).toBeTruthy();
|
||||
// The seconds since the fixture was anchored are the render's to add.
|
||||
expect(screen.getByText(/^Active for 2h(?: \d+s)?$/)).toBeTruthy();
|
||||
expect(screen.getByText("Not yet — still failing")).toBeTruthy();
|
||||
expect(screen.getByText("4")).toBeTruthy();
|
||||
expect(screen.getByText("blocklist.refresh")).toBeTruthy();
|
||||
@@ -195,7 +196,7 @@ test("every code renders its own title, impact and remediation", async () => {
|
||||
test("an event retention has removed shows the server's message, not an empty page", async () => {
|
||||
renderDetail(999);
|
||||
await screen.findByText("no such event");
|
||||
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "All diagnostics" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an unavailable store reports the failure instead of loading forever", async () => {
|
||||
@@ -219,5 +220,5 @@ test("an unavailable store reports the failure instead of loading forever", asyn
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("The server is starting or degraded.");
|
||||
expect(screen.queryByText("Loading event…")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "All diagnostics" })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { formatDuration, formatTime } from "@/lib/format";
|
||||
import { formatCount, formatDuration, formatTime } from "@/lib/format";
|
||||
import { diagnosticPurgeMutation, diagnosticQuery } from "@/lib/queries";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Card from "@/ui/Card";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import SeverityBadge from "./SeverityBadge";
|
||||
import { componentLabel, copyFor } from "./eventCopy";
|
||||
|
||||
const styles = stylex.create({
|
||||
back: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
backIcon: {
|
||||
display: "inline-flex",
|
||||
},
|
||||
headingRow: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
@@ -29,6 +37,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
purgeAction: {
|
||||
marginInlineStart: "auto",
|
||||
@@ -41,12 +50,6 @@ const styles = stylex.create({
|
||||
panel: {
|
||||
marginTop: "1rem",
|
||||
maxWidth: "48rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: "1rem",
|
||||
},
|
||||
facts: {
|
||||
display: "grid",
|
||||
@@ -67,9 +70,10 @@ const styles = stylex.create({
|
||||
},
|
||||
sectionHeading: {
|
||||
marginTop: "1.5rem",
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
fontSize: "1.4rem",
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.015em",
|
||||
},
|
||||
prose: {
|
||||
marginTop: "0.5rem",
|
||||
@@ -81,7 +85,7 @@ const styles = stylex.create({
|
||||
marginTop: "0.5rem",
|
||||
maxWidth: "48rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -100,11 +104,25 @@ const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
loading: {
|
||||
// The heading row, the subject and the seven-row facts panel, which is
|
||||
// what stands above the fold once the event lands.
|
||||
minHeight: "20rem",
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
|
||||
<ArrowLeft size={12} />
|
||||
</span>
|
||||
All diagnostics
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiagnosticDetailPage() {
|
||||
const { id } = useParams({ from: "/shell/diagnostics/$id" });
|
||||
const eventId = Number(id);
|
||||
@@ -132,9 +150,7 @@ export default function DiagnosticDetailPage() {
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<section>
|
||||
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← All diagnostics
|
||||
</Link>
|
||||
<BackLink />
|
||||
<InlineError error={error} onRetry={() => void refetch()} />
|
||||
</section>
|
||||
);
|
||||
@@ -146,9 +162,7 @@ export default function DiagnosticDetailPage() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← All diagnostics
|
||||
</Link>
|
||||
<BackLink />
|
||||
<div {...stylex.props(styles.headingRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>{copy.title}</h1>
|
||||
<SeverityBadge severity={data.severity} />
|
||||
@@ -167,7 +181,11 @@ export default function DiagnosticDetailPage() {
|
||||
<p {...stylex.props(styles.subject)}>{data.subject}</p>
|
||||
<InlineError error={purge.error} />
|
||||
|
||||
<div {...stylex.props(styles.panel)}>
|
||||
<Card
|
||||
title="Details"
|
||||
description="How long this has been failing, how often it recurred, and whether it has cleared."
|
||||
style={styles.panel}
|
||||
>
|
||||
<dl {...stylex.props(styles.facts)}>
|
||||
<dt {...stylex.props(styles.term)}>State</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
@@ -180,7 +198,7 @@ export default function DiagnosticDetailPage() {
|
||||
<dt {...stylex.props(styles.term)}>Last seen</dt>
|
||||
<dd {...stylex.props(styles.value)}>{formatTime(data.last_seen)}</dd>
|
||||
<dt {...stylex.props(styles.term)}>Occurrences</dt>
|
||||
<dd {...stylex.props(styles.value, shared.tabularNums)}>{data.occurrences}</dd>
|
||||
<dd {...stylex.props(styles.value, shared.tabularNums)}>{formatCount(data.occurrences)}</dd>
|
||||
<dt {...stylex.props(styles.term)}>Resolved</dt>
|
||||
<dd {...stylex.props(styles.value)}>
|
||||
{data.resolved_at === null ? "Not yet — still failing" : formatTime(data.resolved_at)}
|
||||
@@ -190,7 +208,7 @@ export default function DiagnosticDetailPage() {
|
||||
<dt {...stylex.props(styles.term)}>Code</dt>
|
||||
<dd {...stylex.props(styles.value, shared.mono)}>{data.code}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<h2 {...stylex.props(styles.sectionHeading)}>Impact</h2>
|
||||
<p {...stylex.props(styles.prose)}>{copy.impact}</p>
|
||||
|
||||
@@ -47,6 +47,15 @@ const RESOLVED = page([
|
||||
event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* The footer count sets its number in tabular figures, so the sentence is split
|
||||
* across elements. Matched on the paragraph's whole text rather than on a
|
||||
* fragment of it.
|
||||
*/
|
||||
function footerLine(text: string): HTMLElement {
|
||||
return screen.getByText((_content, element) => element?.tagName === "P" && element.textContent === text);
|
||||
}
|
||||
|
||||
/** A stubbed response that carries a non-200 status instead of a payload. */
|
||||
class Failure {
|
||||
constructor(
|
||||
@@ -68,7 +77,7 @@ let requested: string[];
|
||||
beforeEach(() => {
|
||||
requested = [];
|
||||
responses = {
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
"/api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
// The health strip at the top of the page; quiet on a healthy box, which is
|
||||
// what every test below wants it to be.
|
||||
"/api/health": health(),
|
||||
@@ -144,7 +153,7 @@ test("active episodes come first, each with its title, subject, age and count",
|
||||
const active = screen.getByText("Blocklist source failed to update").closest("li")!;
|
||||
expect(within(active).getByText("Warning")).toBeTruthy();
|
||||
expect(within(active).getByText("StevenBlack")).toBeTruthy();
|
||||
expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy();
|
||||
expect(within(active).getByText(/Active for 1h(?: \d+s)? · 3 occurrences/)).toBeTruthy();
|
||||
|
||||
const failing = screen.getByText("Upstream failing").closest("li")!;
|
||||
expect(within(failing).getByText("Error")).toBeTruthy();
|
||||
@@ -153,7 +162,7 @@ test("active episodes come first, each with its title, subject, age and count",
|
||||
// The resolved history is a separate section, below the active list.
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Disk space low")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 1 resolved entry — end of history/)).toBeTruthy();
|
||||
expect(footerLine("Showing 1 resolved entry — end of history")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("nothing open reads as good news, not as a broken page", async () => {
|
||||
@@ -223,7 +232,7 @@ test("load more appends the next page of resolved history", async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
await screen.findByText("TLS certificate reload failed");
|
||||
expect(screen.getByText(/Showing 2 resolved entries — end of history/)).toBeTruthy();
|
||||
expect(footerLine("Showing 2 resolved entries — end of history")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an unavailable store reports the failure instead of loading forever", async () => {
|
||||
|
||||
@@ -10,13 +10,13 @@ import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import * as api from "@/lib/api";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { formatDuration, formatTime } from "@/lib/format";
|
||||
import { formatCount, formatDuration, formatTime } from "@/lib/format";
|
||||
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
|
||||
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import HealthStrip from "./HealthStrip";
|
||||
import SeverityBadge from "./SeverityBadge";
|
||||
import { diagnosticsFilterOf } from "./filter";
|
||||
@@ -46,6 +46,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.25rem",
|
||||
@@ -66,9 +67,10 @@ const styles = stylex.create({
|
||||
},
|
||||
sectionHeading: {
|
||||
marginTop: "1.5rem",
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
fontSize: "1.4rem",
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.015em",
|
||||
},
|
||||
sectionHeadingRow: {
|
||||
display: "flex",
|
||||
@@ -94,6 +96,18 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** One card: the badge and title row, the meta line, and the card's padding. */
|
||||
activeLoading: {
|
||||
minHeight: "4rem",
|
||||
},
|
||||
/**
|
||||
* The table header and three rows, at 2.25rem each. Deliberately short of a
|
||||
* full page of history: an install with nothing resolved collapses to one
|
||||
* muted line, and a taller reserve would leave a hole on the common case.
|
||||
*/
|
||||
historyLoading: {
|
||||
minHeight: "9rem",
|
||||
},
|
||||
cardList: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
@@ -103,7 +117,7 @@ const styles = stylex.create({
|
||||
padding: 0,
|
||||
},
|
||||
card: {
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -136,7 +150,7 @@ const styles = stylex.create({
|
||||
},
|
||||
rangeNotice: {
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -150,7 +164,7 @@ const styles = stylex.create({
|
||||
tableWrap: {
|
||||
marginTop: "0.75rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -222,7 +236,7 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
function occurrenceText(count: number): string {
|
||||
return `${count} ${count === 1 ? "occurrence" : "occurrences"}`;
|
||||
return `${formatCount(count)} ${count === 1 ? "occurrence" : "occurrences"}`;
|
||||
}
|
||||
|
||||
function rowsOf(section: Section): DiagnosticEvent[] {
|
||||
@@ -339,11 +353,11 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
|
||||
</Link>
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>{event.subject}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{formatTime(event.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatTime(event.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{event.occurrences}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatCount(event.occurrences)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -436,7 +450,7 @@ export default function DiagnosticsPage() {
|
||||
{active.status === "error" ? (
|
||||
<InlineError error={active.error} onRetry={() => void active.refetch()} />
|
||||
) : active.data === undefined ? (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
<p {...stylex.props(styles.empty, styles.activeLoading, shared.pulse)} role="status">
|
||||
Loading diagnostics…
|
||||
</p>
|
||||
) : activeRows.length === 0 ? (
|
||||
@@ -474,14 +488,14 @@ export default function DiagnosticsPage() {
|
||||
{history.status === "error" ? (
|
||||
<InlineError error={history.error} onRetry={() => void history.refetch()} />
|
||||
) : history.data === undefined ? (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
<p {...stylex.props(styles.empty, styles.historyLoading, shared.pulse)} role="status">
|
||||
Loading history…
|
||||
</p>
|
||||
) : historyRows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>Nothing has failed and recovered in the retained window.</p>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
@@ -509,7 +523,9 @@ export default function DiagnosticsPage() {
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footer, styles.note)}>
|
||||
Showing {historyRows.length} resolved {historyRows.length === 1 ? "entry" : "entries"}
|
||||
Showing{" "}
|
||||
<span {...stylex.props(shared.tabularNums)}>{formatCount(historyRows.length)}</span>{" "}
|
||||
resolved {historyRows.length === 1 ? "entry" : "entries"}
|
||||
{hasMore(history) ? "" : " — end of history"}
|
||||
</p>
|
||||
<MoreButton section={history} />
|
||||
|
||||
@@ -42,7 +42,7 @@ beforeEach(() => {
|
||||
return healthFails ? json({ error: "health unavailable" }, 400) : json(healthBody);
|
||||
}
|
||||
if (url === "/api/version")
|
||||
return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 });
|
||||
return json({ version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 });
|
||||
if (url.startsWith("/api/diagnostics"))
|
||||
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
|
||||
return json({ error: "not stubbed" }, 404);
|
||||
|
||||
@@ -15,13 +15,18 @@
|
||||
* a claim about the current state, until a poll succeeds again.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Circle } from "@phosphor-icons/react/dist/icons/Circle";
|
||||
import { Pause } from "@phosphor-icons/react/dist/icons/Pause";
|
||||
import { Warning } from "@phosphor-icons/react/dist/icons/Warning";
|
||||
import { X } from "@phosphor-icons/react/dist/icons/X";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { healthQuery } from "@/lib/queries";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { healthFacts, type FactLink, type FactTone, type HealthFact } from "./healthFacts";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
@@ -48,7 +53,7 @@ const styles = stylex.create({
|
||||
flexWrap: "wrap",
|
||||
alignItems: "baseline",
|
||||
gap: "0.375rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
paddingInline: "0.625rem",
|
||||
@@ -95,18 +100,34 @@ const styles = stylex.create({
|
||||
danger: {
|
||||
color: colors.dangerText,
|
||||
},
|
||||
icon: {
|
||||
display: "inline-flex",
|
||||
},
|
||||
message: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* One row of facts — a fact's own line, its detail line, and the padding —
|
||||
* plus the extra top margin the list carries. A floor, not a match: below
|
||||
* 1100px the strip stacks and grows past it.
|
||||
*/
|
||||
loading: {
|
||||
minHeight: "3.5rem",
|
||||
},
|
||||
});
|
||||
|
||||
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
|
||||
|
||||
/** Text and icon carry the state; the colour only agrees with them. */
|
||||
const ICONS: Record<FactTone, string> = { ok: "●", notice: "‖", warn: "!", danger: "✕" };
|
||||
const ICONS: Record<FactTone, ReactNode> = {
|
||||
ok: <Circle size={12} weight="fill" />,
|
||||
notice: <Pause size={12} />,
|
||||
warn: <Warning size={12} />,
|
||||
danger: <X size={12} />,
|
||||
};
|
||||
|
||||
function FactLinkAnchor({ link }: { link: FactLink }) {
|
||||
if (link.kind === "filter") {
|
||||
@@ -151,7 +172,7 @@ function FactLinkAnchor({ link }: { link: FactLink }) {
|
||||
function Fact({ fact }: { fact: HealthFact }) {
|
||||
return (
|
||||
<li {...stylex.props(styles.fact, fact.tone === "ok" ? styles.quiet : styles.highlighted)}>
|
||||
<span aria-hidden="true" {...stylex.props(TONES[fact.tone])}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.icon, TONES[fact.tone])}>
|
||||
{ICONS[fact.tone]}
|
||||
</span>
|
||||
<span {...stylex.props(styles.label)}>{fact.label}</span>
|
||||
@@ -169,7 +190,7 @@ export default function HealthStrip() {
|
||||
return health.isError ? (
|
||||
<InlineError error={health.error} onRetry={() => void health.refetch()} />
|
||||
) : (
|
||||
<p role="status" {...stylex.props(styles.message, shared.pulse)}>
|
||||
<p role="status" {...stylex.props(styles.message, styles.loading, shared.pulse)}>
|
||||
Loading status…
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import type { DiagnosticSeverity } from "@/lib/types";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
badge: {
|
||||
display: "inline-block",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
paddingInline: "0.375rem",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* condition that is not healthy is the thing the eye lands on.
|
||||
*/
|
||||
|
||||
import { formatBytes, formatClock } from "@/lib/format";
|
||||
import { formatBytes, formatClock, formatCount } from "@/lib/format";
|
||||
import type { Health } from "@/lib/types";
|
||||
|
||||
export type FactTone = "ok" | "notice" | "warn" | "danger";
|
||||
@@ -33,12 +33,10 @@ export interface HealthFact {
|
||||
link?: FactLink;
|
||||
}
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
/** Kept out of the state rule: rows are lost whether or not the box is losing them now. */
|
||||
function dropText(dropped: number, lastDrop: number | null, locale?: string, timeZone?: string): string | undefined {
|
||||
if (dropped <= 0) return undefined;
|
||||
const count = `${numberFormat.format(dropped)} ${dropped === 1 ? "query" : "queries"} dropped`;
|
||||
const count = `${formatCount(dropped)} ${dropped === 1 ? "query" : "queries"} dropped`;
|
||||
return lastDrop === null ? count : `${count}, last at ${formatClock(lastDrop, locale, timeZone)}`;
|
||||
}
|
||||
|
||||
@@ -103,7 +101,7 @@ export function healthFacts(health: Health, locale?: string, timeZone?: string):
|
||||
label: "Upstreams",
|
||||
tone: upstreams.state === "unavailable" ? "danger" : "ok",
|
||||
value: upstreams.state === "unavailable" ? "None reachable" : "Available",
|
||||
detail: `${upstreams.available} of ${upstreams.total} enabled`,
|
||||
detail: `${formatCount(upstreams.available)} of ${formatCount(upstreams.total)} enabled`,
|
||||
...(upstreams.state === "unavailable"
|
||||
? { link: { kind: "route", to: "/configuration/resolution", label: "Upstreams" } as FactLink }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The cache hit rate as a progress bar (ui-visual-redesign.md): the share of
|
||||
* the window's queries answered from memory, the bar it fills, and under it the
|
||||
* three figures that share went with — hits, what went upstream instead, and
|
||||
* how long an answer took on average.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatCount, formatMicros, formatPercent } from "@/lib/format";
|
||||
import Card from "@/ui/Card";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
body: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
track: {
|
||||
height: "0.5rem",
|
||||
borderRadius: "999px",
|
||||
backgroundColor: colors.chartGreenSurface,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
display: "block",
|
||||
height: "100%",
|
||||
borderRadius: "999px",
|
||||
backgroundColor: colors.chartGreen,
|
||||
},
|
||||
/** Dynamic: the bar's length is the share itself. */
|
||||
fillWidth: (percent: number) => ({ width: `${percent}%` }),
|
||||
percent: {
|
||||
margin: 0,
|
||||
fontSize: "2.5rem",
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
letterSpacing: "-0.02em",
|
||||
color: colors.chartGreen,
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
split: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "1.5rem",
|
||||
margin: 0,
|
||||
marginTop: "0.5rem",
|
||||
paddingTop: "1rem",
|
||||
borderTopWidth: 1,
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
/** Label first in the DOM, figure on top visually. */
|
||||
splitItem: {
|
||||
display: "flex",
|
||||
flexDirection: "column-reverse",
|
||||
gap: "0.125rem",
|
||||
},
|
||||
splitValue: {
|
||||
margin: 0,
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.5rem",
|
||||
fontWeight: 550,
|
||||
color: colors.text,
|
||||
},
|
||||
splitLabel: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export interface CacheCardData {
|
||||
queries: number;
|
||||
/** Answers served from cache: the window's `cached` buckets summed. */
|
||||
hits: number;
|
||||
/** Answers that went to an upstream resolver or a forward zone. */
|
||||
forwarded: number;
|
||||
avg_response_time_us: number | null;
|
||||
}
|
||||
|
||||
export default function CacheCard({ data }: { data: CacheCardData }) {
|
||||
const share = data.queries === 0 ? null : data.hits / data.queries;
|
||||
const percent = share === null ? "—" : formatPercent(share);
|
||||
return (
|
||||
<Card
|
||||
title="Cache hit rate"
|
||||
description="Answers served straight from memory, without asking an upstream resolver."
|
||||
>
|
||||
<div {...stylex.props(styles.body)}>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={
|
||||
share === null ? "No queries in this period" : `${percent} of queries served from cache`
|
||||
}
|
||||
{...stylex.props(styles.track)}
|
||||
>
|
||||
<span {...stylex.props(styles.fill, styles.fillWidth(share === null ? 0 : share * 100))} />
|
||||
</div>
|
||||
<p {...stylex.props(styles.percent, shared.tabularNums)}>{percent}</p>
|
||||
<p {...stylex.props(styles.note)}>of queries answered from cache</p>
|
||||
<dl {...stylex.props(styles.split)}>
|
||||
<div {...stylex.props(styles.splitItem)}>
|
||||
<dt {...stylex.props(styles.splitLabel)}>cache hits</dt>
|
||||
<dd {...stylex.props(styles.splitValue, shared.tabularNums)}>{formatCount(data.hits)}</dd>
|
||||
</div>
|
||||
<div {...stylex.props(styles.splitItem)}>
|
||||
<dt {...stylex.props(styles.splitLabel)}>forwarded upstream</dt>
|
||||
<dd {...stylex.props(styles.splitValue, shared.tabularNums)}>{formatCount(data.forwarded)}</dd>
|
||||
</div>
|
||||
<div {...stylex.props(styles.splitItem)}>
|
||||
<dt {...stylex.props(styles.splitLabel)}>avg response</dt>
|
||||
<dd {...stylex.props(styles.splitValue, shared.tabularNums)}>
|
||||
{data.avg_response_time_us === null ? "—" : formatMicros(data.avg_response_time_us)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
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, clientSeriesColor, 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 coloured by rank, and Other in its own gray", () => {
|
||||
const { container } = render(
|
||||
clients(
|
||||
[
|
||||
{ client: "192.0.2.30", buckets: [10] },
|
||||
{ client: "192.0.2.31", buckets: [4] },
|
||||
],
|
||||
[5],
|
||||
),
|
||||
);
|
||||
|
||||
const fills = Array.from(container.querySelectorAll("rect"))
|
||||
.map((rect) => rect.getAttribute("fill"))
|
||||
.filter((fill) => fill !== "transparent");
|
||||
expect(fills).toEqual([clientSeriesColor(0), clientSeriesColor(1), seriesColor(OTHER_KEY)]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The blank lines the owner saw across the chart: two touching fills with an
|
||||
* antialiased seam of ground between them. Every segment that rests on another
|
||||
* reaches half a unit down into it; the bottom segment stops at the baseline.
|
||||
*/
|
||||
test("a segment resting on another overlaps it by half a unit, so no seam can open", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [10]));
|
||||
|
||||
const [lower, upper] = Array.from(container.querySelectorAll("rect")).filter(
|
||||
(rect) => rect.getAttribute("fill") !== "transparent",
|
||||
);
|
||||
const lowerTop = Number(lower.getAttribute("y"));
|
||||
const upperBottom = Number(upper.getAttribute("y")) + Number(upper.getAttribute("height"));
|
||||
expect(upperBottom - lowerTop).toBeCloseTo(0.5, 6);
|
||||
// The bottom segment ends exactly on the baseline (plot bottom is 240 - 22).
|
||||
expect(Number(lower.getAttribute("y")) + Number(lower.getAttribute("height"))).toBeCloseTo(218, 6);
|
||||
// No segment strokes itself any more: the bleed does the separating work.
|
||||
expect(lower.getAttribute("stroke")).toBeNull();
|
||||
});
|
||||
|
||||
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(["20", "10", "5", "35"]);
|
||||
|
||||
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(["22", "11", "6", "39"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 busiest clients, then Other, then the total, 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));
|
||||
// Busiest first in this bucket, whatever the legend's order.
|
||||
expect(Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent)).toEqual([
|
||||
"192.0.2.31",
|
||||
"192.0.2.30",
|
||||
"Other",
|
||||
"All clients",
|
||||
]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["20", "10", "5", "35"]);
|
||||
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
|
||||
expect(swatches[0]).toContain(clientSeriesColor(1));
|
||||
expect(swatches[1]).toContain(clientSeriesColor(0));
|
||||
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"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Eight named rows would be a table. The reader pointing at a spike wants to
|
||||
* know who made it, so the tooltip stops at the bucket's four busiest clients
|
||||
* and leaves the legend and the hidden table to name the rest.
|
||||
*/
|
||||
test("a bucket's tooltip lists at most four named clients, the quiet ones dropped", () => {
|
||||
const named = Array.from({ length: 6 }, (_, i) => ({ client: `192.0.2.${40 + i}`, buckets: [i + 1, 0] }));
|
||||
const { container } = render(clients(named, [0, 0]));
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
|
||||
expect(terms.map((term) => term.textContent)).toEqual([
|
||||
"192.0.2.45",
|
||||
"192.0.2.44",
|
||||
"192.0.2.43",
|
||||
"192.0.2.42",
|
||||
"Other",
|
||||
"All clients",
|
||||
]);
|
||||
expect(screen.getByRole("table").querySelectorAll("th[scope=col]")).toHaveLength(8);
|
||||
});
|
||||
|
||||
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 keeps it in the legend and the table, at zero", () => {
|
||||
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",
|
||||
"Other",
|
||||
]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "192.0.2.30", "Other"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
|
||||
expect(terms.map((term) => term.textContent)).toEqual(["192.0.2.30", "Other", "All clients"]);
|
||||
});
|
||||
|
||||
test("the hidden table groups its counts like every other figure on the page", () => {
|
||||
render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0]));
|
||||
expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy();
|
||||
});
|
||||
@@ -2,256 +2,197 @@
|
||||
* 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 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 goes by rank (`seriesColors.ts`): the busiest
|
||||
* client wears the first palette hue, and the legend names every band.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { StatsClients } from "@/lib/types";
|
||||
import { Group } from "@visx/group";
|
||||
import { BarStack } from "@visx/shape";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
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 { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
|
||||
import {
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartLegend,
|
||||
ChartRoot,
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
HitBands,
|
||||
STACK_BLEED,
|
||||
StackSegment,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
slotCenter,
|
||||
useActiveIndex,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
valueTicks,
|
||||
type TooltipContent,
|
||||
} from "./chartKit";
|
||||
import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors";
|
||||
|
||||
const CHART_HEIGHT = 240;
|
||||
const FALLBACK_WIDTH = 640;
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
root: {
|
||||
position: "relative",
|
||||
},
|
||||
gridLine: {
|
||||
stroke: colors.border,
|
||||
},
|
||||
axisLine: {
|
||||
stroke: colors.borderStrong,
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
legend: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
columnGap: "1rem",
|
||||
rowGap: "0.25rem",
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
legendItem: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the colour the bars are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
setWidth(el.clientWidth);
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return [ref, width];
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
function formatTick(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
}
|
||||
/** How many named clients a bucket's tooltip lists before the aggregate and the total. */
|
||||
const TOOLTIP_CLIENTS = 4;
|
||||
|
||||
interface Series {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Kept beside the label so a renamed client is still identifiable by address. */
|
||||
address: string | null;
|
||||
color: string;
|
||||
buckets: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* "Other" last, so it sits at the top of every column rather than under a
|
||||
* client, and always present: the response always carries the series, and a
|
||||
* legend that dropped it on a quiet period would make the reader think the
|
||||
* chart's clients were all of them.
|
||||
* "Other" last, so it sits at the top of every column rather than under a client.
|
||||
* It is always a series, even at zero across the window (ui-visual-redesign.md:
|
||||
* eight named clients plus Other): a reader comparing two scopes sees the same
|
||||
* legend in both, and a zero says the named clients were the whole story.
|
||||
*/
|
||||
function seriesOf(data: StatsClients, names: ClientNames): Series[] {
|
||||
const named = data.clients.map((client) => ({
|
||||
function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
|
||||
const named = data.clients.map((client, rank) => ({
|
||||
key: clientKey(client.client),
|
||||
// The name if the client is registered under one, the address otherwise —
|
||||
// the same precedence and the same lookup the query tables use. The colour
|
||||
// keys on the address regardless, so naming a client never repaints it.
|
||||
// the same precedence and the same lookup the query tables use.
|
||||
label: clientLabel(client.client, names)?.text ?? client.client,
|
||||
address: client.client,
|
||||
color: seriesColor(clientKey(client.client)),
|
||||
color: clientSeriesColor(rank),
|
||||
buckets: client.buckets,
|
||||
}));
|
||||
return [
|
||||
...named,
|
||||
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
|
||||
];
|
||||
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
|
||||
}
|
||||
|
||||
export default function ClientChart({ data }: { data: StatsClients }) {
|
||||
const [containerRef, measuredWidth] = useContainerWidth();
|
||||
/**
|
||||
* The slice of the Overview body this chart draws. Declared here rather than
|
||||
* taken whole, so what the chart reads is stated where it is read.
|
||||
*/
|
||||
export interface ClientChartData {
|
||||
since: number;
|
||||
bucket_seconds: number;
|
||||
clients: OverviewClientSeries[];
|
||||
other: number[];
|
||||
}
|
||||
|
||||
/** One column: the timestamp plus one entry per series, keyed by the series key. */
|
||||
type Column = { ts: number } & Record<string, number>;
|
||||
|
||||
export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
const [containerRef, width] = useMeasuredWidth();
|
||||
// A hover survives a re-render only while it still names the same bucket at
|
||||
// the same place: a poll that rolls the window, or a resize, retires it.
|
||||
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.other.length}:${width}`);
|
||||
const names = useClientNames();
|
||||
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
|
||||
|
||||
const series = seriesOf(data, names);
|
||||
const bucketCount = data.other.length;
|
||||
const columns: Column[] = Array.from({ length: bucketCount }, (_, i) => {
|
||||
const column: Column = { ts: data.since + i * data.bucket_seconds };
|
||||
for (const one of series) column[one.key] = one.buckets[i] ?? 0;
|
||||
return column;
|
||||
});
|
||||
|
||||
if (bucketCount === 0) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
if (bucketCount === 0 || columns.every((column) => series.every((one) => column[one.key] === 0))) {
|
||||
return <EmptyChart containerRef={containerRef} />;
|
||||
}
|
||||
|
||||
const columns = Array.from({ length: bucketCount }, (_, i) => ({
|
||||
ts: data.since + i * data.bucket_seconds,
|
||||
values: series.map((one) => one.buckets[i] ?? 0),
|
||||
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, Math.max(...totals));
|
||||
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 centers = timestamps.map((ts) => slotCenter(xScale, ts, plot));
|
||||
|
||||
/**
|
||||
* The bucket's busiest clients, then Other, then the total — eight named
|
||||
* rows would be a table, and the reader pointing at a spike wants to know
|
||||
* who made it. Other is always there, at zero when the named clients were
|
||||
* the whole bucket, so the rows read the same from bucket to bucket.
|
||||
*/
|
||||
function tooltipOf(index: number): TooltipContent {
|
||||
const column = columns[index];
|
||||
const named = series
|
||||
.filter((one) => one.key !== OTHER_KEY && column[one.key] > 0)
|
||||
.sort((a, b) => column[b.key] - column[a.key])
|
||||
.slice(0, TOOLTIP_CLIENTS);
|
||||
const other = series.find((one) => one.key === OTHER_KEY);
|
||||
const rows = [...named, ...(other !== undefined ? [other] : [])].map((one) => ({
|
||||
key: one.key,
|
||||
label: one.label,
|
||||
color: one.color,
|
||||
value: formatCount(column[one.key]),
|
||||
}));
|
||||
if (columns.every((column) => column.values.every((value) => value === 0))) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
return {
|
||||
title: formatTime(column.ts),
|
||||
rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }],
|
||||
};
|
||||
}
|
||||
|
||||
const layout = layoutStacked(columns, width, CHART_HEIGHT);
|
||||
const baseline = layout.plot.y + layout.plot.height;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.root)}>
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Client activity over time, ${bucketCount} buckets, ${series.length} series`}
|
||||
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{layout.yTicks.map((tick) => (
|
||||
<g key={tick.value}>
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
{...stylex.props(styles.gridLine)}
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={xScale}
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
<BarStack<Column, string>
|
||||
data={columns}
|
||||
keys={series.map((one) => one.key)}
|
||||
x={(column) => column.ts}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
color={(key) => colorOf.get(key) ?? seriesColor(key)}
|
||||
>
|
||||
{compact.format(tick.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={baseline}
|
||||
y2={baseline}
|
||||
{...stylex.props(styles.axisLine)}
|
||||
{(stacks) =>
|
||||
columns.map((column, index) => (
|
||||
<Group
|
||||
key={column.ts}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
>
|
||||
{stacks.map((stack) => {
|
||||
const bar = stack.bars[index];
|
||||
const restsOnAnother = bar.y + bar.height < plot.bottom - STACK_BLEED;
|
||||
return (
|
||||
<StackSegment
|
||||
key={stack.key}
|
||||
x={bar.x}
|
||||
y={bar.y}
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
fill={bar.color}
|
||||
bleed={restsOnAnother ? STACK_BLEED : 0}
|
||||
/>
|
||||
{layout.xTicks.map((tick) => (
|
||||
<text
|
||||
key={tick.ts}
|
||||
x={tick.x}
|
||||
y={baseline + 14}
|
||||
textAnchor="middle"
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
>
|
||||
{formatTick(tick.ts, data.bucket_seconds)}
|
||||
</text>
|
||||
))}
|
||||
{layout.columns.map((column) => (
|
||||
<g key={column.ts}>
|
||||
{column.segments.map((rect, index) =>
|
||||
rect.height <= 0 ? null : (
|
||||
<rect
|
||||
key={series[index].key}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
fill={series[index].color}
|
||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<rect
|
||||
x={column.slot.x}
|
||||
y={column.slot.y}
|
||||
width={column.slot.width}
|
||||
height={column.slot.height}
|
||||
fill="transparent"
|
||||
>
|
||||
<title>
|
||||
{`${formatTime(column.ts)}: ${column.total} ${column.total === 1 ? "query" : "queries"}`}
|
||||
</title>
|
||||
</rect>
|
||||
</g>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
|
||||
</svg>
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{series.map((one) => (
|
||||
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
|
||||
{one.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip index={hovered.index} content={tooltipOf(hovered.index)} left={centers[hovered.index]} />
|
||||
)}
|
||||
<ChartLegend entries={series} />
|
||||
<div {...stylex.props(shared.srOnly)}>
|
||||
<table>
|
||||
<caption>Queries per client per time bucket</caption>
|
||||
@@ -269,14 +210,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}>{formatCount(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,-63.5")).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("M63.5,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: the
|
||||
// decision record's ring, 127px across and 15px thick.
|
||||
expect(arcRadii(d)).toEqual([63.5, 63.5, 48.5, 48.5]);
|
||||
});
|
||||
|
||||
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.00%")).toHaveLength(2);
|
||||
expect(screen.getAllByText("25.00%")).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.00%"]);
|
||||
// The same share the legend and the hidden table already print for this slice.
|
||||
expect(screen.getAllByText("75.00%")).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 56 around the box's centre at 76 — (115.6, 115.6) 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 draws.
|
||||
const tooltipBox = container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
expect(tooltipBox.style.transform).toBe("translate(124px, 124px)");
|
||||
});
|
||||
|
||||
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.00%"]);
|
||||
|
||||
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.00%",
|
||||
]);
|
||||
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,27 +13,42 @@
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Group } from "@visx/group";
|
||||
import { Pie } from "@visx/shape";
|
||||
import { formatCount, formatPercent } from "@/lib/format";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { layoutDonut, type DonutSlice } from "./donutLayout";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { ChartTooltip, useActiveIndex, type TooltipContent } from "./chartKit";
|
||||
|
||||
const SIZE = 180;
|
||||
const THICKNESS = 36;
|
||||
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 numberFormat = new Intl.NumberFormat();
|
||||
/** The decision record's ring: a 152px box, a 15px band, the total in the hole. */
|
||||
const SIZE = 152;
|
||||
const THICKNESS = 15;
|
||||
const OUTER_RADIUS = SIZE / 2 - 12.5;
|
||||
const INNER_RADIUS = OUTER_RADIUS - THICKNESS;
|
||||
|
||||
/** The width at which the page puts the two donuts side by side, and the page's
|
||||
* own grid switches on the same query. StyleX will not take it from an import,
|
||||
* so it is written out in both modules and must be changed in both. */
|
||||
const TWO_COLUMN = "@media (min-width: 1280px)";
|
||||
/** The gap `body` puts between the ring and the legend, in pixels: 1.5rem. */
|
||||
const BODY_GAP = 24;
|
||||
/** One `legend` row, in pixels: its 1.25rem line height plus the divider's padding. */
|
||||
const LEGEND_ROW = 30;
|
||||
|
||||
const styles = stylex.create({
|
||||
/** The reserve is the ring and one legend row, so an empty panel stands as tall as a filled one. */
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: SIZE,
|
||||
borderRadius: "0.25rem",
|
||||
minHeight: SIZE + BODY_GAP + LEGEND_ROW,
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -41,46 +56,52 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* Centred while the panels are stacked, left-anchored once they are side by
|
||||
* side. Stacked, the panel is as wide as the page and a ring pinned to the
|
||||
* left edge reads as a mistake; in a column it is one of a pair and lines up
|
||||
* with everything above it.
|
||||
*/
|
||||
body: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" },
|
||||
gap: "1.25rem",
|
||||
gap: "1.5rem",
|
||||
},
|
||||
/** 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,
|
||||
},
|
||||
centerTotal: {
|
||||
fill: colors.text,
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.01em",
|
||||
},
|
||||
centerUnit: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "0.75rem",
|
||||
},
|
||||
/**
|
||||
* Capped and left-anchored. Without the cap the row justifies across whatever
|
||||
* the panel is given — most of a metre of whitespace on a wide monitor — and a
|
||||
* label stops reading as belonging to the count opposite it.
|
||||
*/
|
||||
legend: {
|
||||
flex: 1,
|
||||
minWidth: "12rem",
|
||||
maxWidth: "24rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** Rows are divided by hairlines, not by a gap, so the list reads as one table. */
|
||||
legendItem: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: "0.5rem",
|
||||
paddingBlock: "0.3125rem",
|
||||
borderTopWidth: { default: 0, ":not(:first-child)": 1 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
swatch: {
|
||||
pointerEvents: "none",
|
||||
flexShrink: 0,
|
||||
alignSelf: "center",
|
||||
display: "inline-block",
|
||||
@@ -101,18 +122,30 @@ const styles = stylex.create({
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
count: {
|
||||
figures: {
|
||||
display: "inline-flex",
|
||||
gap: "0.375rem",
|
||||
whiteSpace: "nowrap",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
share: {
|
||||
minWidth: "3rem",
|
||||
textAlign: "right",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
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: SIZE / 2 + Math.sin(middle) * radius,
|
||||
top: SIZE / 2 - Math.cos(middle) * radius,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Donut({
|
||||
@@ -123,59 +156,113 @@ export default function Donut({
|
||||
slices: DonutSlice[];
|
||||
/** Names the hidden table, so a screen reader knows which breakdown it is in. */
|
||||
caption: string;
|
||||
/** The column header for the counted thing, e.g. "Queries". */
|
||||
/** The counted thing, e.g. "Queries": the table's column header and, lowercased, the word under the total. */
|
||||
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: formatCount(slice.value) },
|
||||
{ key: "share", label: "Share", value: formatPercent(slice.value / total) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return <div {...stylex.props(styles.empty)}>No queries in this period.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.body)}>
|
||||
<div {...stylex.props(styles.ring)}>
|
||||
{/* The ring stays out of the accessibility tree even though it is a
|
||||
pointer target: the tooltip repeats what the legend beside it
|
||||
already says in text, so nothing here is the only copy. */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
width={SIZE}
|
||||
height={SIZE}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
{...stylex.props(styles.ring)}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{layout.arcs.map((arc) => (
|
||||
// The stroke is what keeps a shared hue from lying. Colour is a pure
|
||||
// function of identity, so two neighbouring slices can come out the
|
||||
// same; outlined in the panel's own colour they still read as two
|
||||
// shapes rather than merging into one. Attributes rather than a
|
||||
// class, as the client chart's segments are, so the separation is
|
||||
// visible to a test and not only to a stylesheet.
|
||||
{/* Arc paths are generated around the origin, and `Pie`'s own
|
||||
`top`/`left` group is skipped when it is given a render prop, so
|
||||
the ring is centred here instead. */}
|
||||
<Group top={SIZE / 2} left={SIZE / 2}>
|
||||
<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: the routes
|
||||
// ring colours by identity, so two neighbouring slices can come
|
||||
// out the same, and outlined in the panel's own colour they
|
||||
// still read as two shapes rather than merging into one.
|
||||
<path
|
||||
key={arc.slice.key}
|
||||
d={arc.d}
|
||||
fill={arc.slice.color}
|
||||
fillRule="evenodd"
|
||||
key={arc.data.key}
|
||||
d={path(arc) ?? ""}
|
||||
fill={arc.data.color}
|
||||
stroke={colors.surfaceRaised}
|
||||
strokeWidth={1}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
onMouseEnter={() => hovered.show(index)}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
}
|
||||
</Pie>
|
||||
<text y={-2} textAnchor="middle" {...stylex.props(styles.centerTotal, shared.tabularNums)}>
|
||||
{formatCount(total)}
|
||||
</text>
|
||||
<text y={15} textAnchor="middle" {...stylex.props(styles.centerUnit)}>
|
||||
{unit.toLowerCase()}
|
||||
</text>
|
||||
</Group>
|
||||
</svg>
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{layout.arcs.map((arc) => (
|
||||
<li key={arc.slice.key} {...stylex.props(styles.legendItem)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchColor(arc.slice.color))}
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(hovered.index)}
|
||||
{...sliceAnchor(drawn, hovered.index, total)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{drawn.map((slice) => (
|
||||
<li key={slice.key} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(slice.color))} />
|
||||
<span {...stylex.props(styles.label)}>
|
||||
{arc.slice.label}
|
||||
{arc.slice.secondary !== undefined && (
|
||||
<span {...stylex.props(styles.secondary)}>{arc.slice.secondary}</span>
|
||||
{slice.label}
|
||||
{slice.secondary !== undefined && (
|
||||
<span {...stylex.props(styles.secondary)}>{slice.secondary}</span>
|
||||
)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
||||
{numberFormat.format(arc.slice.value)}
|
||||
<span {...stylex.props(styles.figures, shared.tabularNums)}>
|
||||
<span>{formatCount(slice.value)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span {...stylex.props(styles.share)}>{formatPercent(slice.value / total)}</span>
|
||||
</span>
|
||||
<span {...stylex.props(styles.share, shared.tabularNums)}>{sharePercent(arc.share)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -190,15 +277,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>{formatCount(slice.value)}</td>
|
||||
<td>{formatPercent(slice.value / total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* The part of Overview that does not wait for anything: the heading, the
|
||||
* toolbar with the device and period selectors, 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 { useCallback } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||
import { clientsQuery } from "@/lib/queries";
|
||||
import type { Period } from "@/lib/types";
|
||||
import Select, { type SelectOption } from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { DEFAULT_PERIOD, PERIODS } from "./period";
|
||||
|
||||
const NARROW = "@media (max-width: 800px)";
|
||||
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
"1h": "Last hour",
|
||||
"24h": "Last 24 hours",
|
||||
"7d": "Last 7 days",
|
||||
"30d": "Last 30 days",
|
||||
};
|
||||
|
||||
const PERIOD_OPTIONS: SelectOption[] = PERIODS.map((period) => ({ value: period, label: PERIOD_LABELS[period] }));
|
||||
|
||||
/** The Select's key for the whole household; a client scope is the address itself. */
|
||||
const ALL_DEVICES = "";
|
||||
|
||||
const styles = stylex.create({
|
||||
page: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.25rem",
|
||||
},
|
||||
headingRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
margin: 0,
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.015em",
|
||||
textWrap: "balance",
|
||||
},
|
||||
/** Device on the left, period on the right; on a phone the pair takes the whole row. */
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
// Without this the row's minimum is both labels at full length, and a
|
||||
// long device name pushes the period picker past the viewport edge.
|
||||
minWidth: 0,
|
||||
flexBasis: { default: null, [NARROW]: "100%" },
|
||||
},
|
||||
control: {
|
||||
minWidth: { default: "11rem", [NARROW]: 0 },
|
||||
maxWidth: { default: "20rem", [NARROW]: "none" },
|
||||
flex: { default: null, [NARROW]: 1 },
|
||||
},
|
||||
/** Under the Device control: the list behind it did not load, so the control offers the household only. */
|
||||
devicesFailed: {
|
||||
margin: 0,
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
retry: {
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
backgroundColor: "transparent",
|
||||
font: "inherit",
|
||||
color: colors.primaryOnSurface,
|
||||
cursor: "pointer",
|
||||
},
|
||||
/**
|
||||
* The height approximates the filled overview — stat tiles, two 240px charts
|
||||
* and the row of cards under them — so that the page does not jump when the
|
||||
* window lands. That is where the number comes from.
|
||||
*/
|
||||
loading: {
|
||||
minHeight: "48rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export interface OverviewScope {
|
||||
period: Period;
|
||||
client: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The devices the reader can scope to: the whole household first, then every
|
||||
* registered client under its name. A scope the URL carries that the list has
|
||||
* never seen is still offered, as its address, so the control shows the scope
|
||||
* the page is actually under rather than silently claiming the household. A
|
||||
* list that failed to load is said so under the control, with a retry: the
|
||||
* household-only list is a failure, not the answer.
|
||||
*/
|
||||
function useDeviceOptions(client: string | undefined) {
|
||||
const clients = useQuery(clientsQuery());
|
||||
const names = useClientNames();
|
||||
const known = (clients.data ?? []).map((one) => ({
|
||||
value: one.ip,
|
||||
label: clientLabel(one.ip, names)?.text ?? one.ip,
|
||||
}));
|
||||
const options = [{ value: ALL_DEVICES, label: "All devices" }, ...known];
|
||||
if (client !== undefined && !known.some((option) => option.value === client)) {
|
||||
options.push({ value: client, label: client });
|
||||
}
|
||||
const { refetch } = clients;
|
||||
const retry = useCallback(() => void refetch(), [refetch]);
|
||||
return { options, failed: clients.isError, retry };
|
||||
}
|
||||
|
||||
export function OverviewToolbar({
|
||||
scope,
|
||||
onChange,
|
||||
}: {
|
||||
scope: OverviewScope;
|
||||
onChange: (next: OverviewScope) => void;
|
||||
}) {
|
||||
const devices = useDeviceOptions(scope.client);
|
||||
return (
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<div {...stylex.props(styles.control)}>
|
||||
<Select
|
||||
aria-label="Device"
|
||||
variant="toolbar"
|
||||
options={devices.options}
|
||||
value={scope.client ?? ALL_DEVICES}
|
||||
onChange={(value) => onChange({ ...scope, client: value === ALL_DEVICES ? undefined : value })}
|
||||
/>
|
||||
{devices.failed && (
|
||||
<p role="alert" {...stylex.props(styles.devicesFailed)}>
|
||||
Device list unavailable.{" "}
|
||||
<button type="button" onClick={devices.retry} {...stylex.props(styles.retry, shared.focusRing)}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div {...stylex.props(styles.control)}>
|
||||
<Select
|
||||
aria-label="Period"
|
||||
variant="toolbar"
|
||||
options={PERIOD_OPTIONS}
|
||||
value={scope.period}
|
||||
onChange={(value) => onChange({ ...scope, period: value as Period })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewLoading() {
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.loading, shared.pulse)}>
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewFrame({
|
||||
scope,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
scope: OverviewScope;
|
||||
onChange: (next: OverviewScope) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div {...stylex.props(styles.page)}>
|
||||
<div {...stylex.props(styles.headingRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Overview</h1>
|
||||
<OverviewToolbar scope={scope} onChange={onChange} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope the URL names, and the navigation that rewrites it. The default
|
||||
* period and the household scope are the absence of a parameter, so a link to
|
||||
* the plain page stays `/overview` rather than growing `?period=24h`.
|
||||
*/
|
||||
export function useOverviewScope(): [OverviewScope, (next: OverviewScope) => void] {
|
||||
const search = useSearch({ from: "/shell/overview" });
|
||||
const navigate = useNavigate({ from: "/overview" });
|
||||
const scope = { period: search.period ?? DEFAULT_PERIOD, client: search.client };
|
||||
const setScope = (next: OverviewScope) =>
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
period: next.period === DEFAULT_PERIOD ? undefined : next.period,
|
||||
client: next.client,
|
||||
}),
|
||||
});
|
||||
return [scope, setScope];
|
||||
}
|
||||
|
||||
/**
|
||||
* The route's pending surface. The toolbar stays live because it only writes
|
||||
* the search parameters, which the route already re-reads on its own.
|
||||
*/
|
||||
export function OverviewPending() {
|
||||
const [scope, setScope] = useOverviewScope();
|
||||
return (
|
||||
<OverviewFrame scope={scope} onChange={setScope}>
|
||||
<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 { clientSeriesColor, typeRampColor } 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({
|
||||
@@ -149,7 +113,7 @@ beforeEach(() => {
|
||||
}
|
||||
if (url === "/api/health") return json(healthBody);
|
||||
if (url === "/api/version")
|
||||
return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 });
|
||||
return json({ version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 });
|
||||
if (url.startsWith("/api/diagnostics")) {
|
||||
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
|
||||
}
|
||||
@@ -180,6 +144,26 @@ function panel(name: string): HTMLElement {
|
||||
return section;
|
||||
}
|
||||
|
||||
/** The toolbar's two selectors, named by their aria-label (RAC folds the value into the name too). */
|
||||
function periodTrigger(): HTMLElement {
|
||||
return screen.getByRole("button", { name: /Period/ });
|
||||
}
|
||||
|
||||
function deviceTrigger(): HTMLElement {
|
||||
return screen.getByRole("button", { name: /Device/ });
|
||||
}
|
||||
|
||||
/** RAC opens a Select from the keyboard as readily as from a pointer. */
|
||||
function open(trigger: HTMLElement) {
|
||||
fireEvent.keyDown(trigger, { key: "Enter" });
|
||||
fireEvent.keyUp(trigger, { key: "Enter" });
|
||||
}
|
||||
|
||||
/** Whether any request so far carried this query string fragment. */
|
||||
function requested(fragment: string): boolean {
|
||||
return vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes(fragment));
|
||||
}
|
||||
|
||||
test("the root path lands on Overview rather than aliasing it", async () => {
|
||||
const router = renderApp("/");
|
||||
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
||||
@@ -191,7 +175,7 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
|
||||
// one panel sharing a hue. The stroke is what stops neighbours from merging
|
||||
// into one shape, which makes it part of the contract rather than decoration.
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
await screen.findAllByText("1,000");
|
||||
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
|
||||
|
||||
const arcs = Array.from(panel("Query types").querySelectorAll("svg path"));
|
||||
@@ -203,25 +187,43 @@ 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 types ring steps the accent's ramp from the busiest type outward", async () => {
|
||||
// `Donut` renders the colour it is handed and never recomputes one, so the
|
||||
// mapping from rank to ramp step is the page's job and is pinned here.
|
||||
renderApp();
|
||||
await screen.findAllByText("1,000");
|
||||
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
|
||||
|
||||
const swatchOf = (label: string) => {
|
||||
const item = within(panel("Query types")).getAllByText(label)[0].closest("li") as HTMLElement;
|
||||
return (item.querySelector("span[aria-hidden]") as HTMLElement).getAttribute("style");
|
||||
};
|
||||
expect(swatchOf("A")).toContain(typeRampColor(0));
|
||||
expect(swatchOf("AAAA")).toContain(typeRampColor(1));
|
||||
expect(swatchOf("Unknown")).toContain(typeRampColor(2));
|
||||
});
|
||||
|
||||
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(periodTrigger().textContent).toContain("Last 24 hours");
|
||||
// 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.findAllByText("1,000");
|
||||
expect(screen.queryByText("Loading…")).toBeNull();
|
||||
});
|
||||
|
||||
test("a registered client is named in the chart, an unregistered one keeps its address", async () => {
|
||||
@@ -236,6 +238,16 @@ test("a registered client is named in the chart, an unregistered one keeps its a
|
||||
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
|
||||
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
|
||||
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
|
||||
|
||||
// Nor on hover: a pointer-only tooltip would say what the design just chose
|
||||
// not to, and only to a reader holding a mouse.
|
||||
const legendItem = within(chart)
|
||||
.getAllByText("kitchen-pi")
|
||||
.map((node) => node.closest("li"))
|
||||
.find((node) => node !== null);
|
||||
expect(legendItem).toBeTruthy();
|
||||
expect(legendItem?.getAttribute("title")).toBeNull();
|
||||
expect(within(chart).getByRole("list").querySelectorAll("[title]")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a client named only by reverse DNS is named by it too", async () => {
|
||||
@@ -246,9 +258,9 @@ test("a client named only by reverse DNS is named by it too", async () => {
|
||||
await waitFor(() => expect(within(chart).getAllByText("laptop.lan")).toHaveLength(2));
|
||||
});
|
||||
|
||||
test("naming a client does not recolour its series", async () => {
|
||||
test("naming a client does not recolour its series: colour goes by rank", async () => {
|
||||
// The rename the palette must not notice: the swatch beside "kitchen-pi" is
|
||||
// the colour of the address it was drawn under, not of the label on screen.
|
||||
// the busiest client's hue, whatever the label on screen says.
|
||||
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
|
||||
renderApp();
|
||||
const chart = await waitFor(() => panel("Client activity over time"));
|
||||
@@ -256,12 +268,13 @@ test("naming a client does not recolour its series", async () => {
|
||||
|
||||
const item = within(chart).getAllByText("kitchen-pi")[0].closest("li") as HTMLElement;
|
||||
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
|
||||
expect(swatch.getAttribute("style")).toContain(seriesColor(clientKey("192.0.2.30")));
|
||||
expect(swatch.getAttribute("style")).toContain(clientSeriesColor(0));
|
||||
});
|
||||
|
||||
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 keeps Other in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. Other is still a series, so the
|
||||
// legend reads the same in every scope and its zero says the named clients
|
||||
// were the whole story.
|
||||
renderApp();
|
||||
await screen.findByRole("heading", { name: "Client activity over time" });
|
||||
|
||||
@@ -269,18 +282,28 @@ 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).getAllByText("Other")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => {
|
||||
test("the page is four tiles, two charts, the cache card and two donuts — no status or issues sections", async () => {
|
||||
renderApp();
|
||||
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
||||
await screen.findByText("1,000");
|
||||
await screen.findAllByText("1,000");
|
||||
|
||||
for (const name of ["Queries over time", "Client activity over time", "Query types", "Upstream servers"]) {
|
||||
for (const name of [
|
||||
"Queries over time",
|
||||
"Client activity over time",
|
||||
"Cache hit rate",
|
||||
"Query types",
|
||||
"Upstream servers",
|
||||
]) {
|
||||
expect(screen.getByRole("heading", { name })).toBeTruthy();
|
||||
}
|
||||
// Every card leads with its title and a one-line description under it.
|
||||
for (const section of screen.getAllByRole("region")) {
|
||||
expect(section.querySelector("h2 + p")?.textContent).toBeTruthy();
|
||||
}
|
||||
// The sections the layout ruling removed, and the widgets the Dashboard lost.
|
||||
expect(screen.queryByRole("heading", { name: "Current status" })).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "Active issues" })).toBeNull();
|
||||
@@ -291,11 +314,18 @@ test("the page is four tiles, two charts and two donuts — no status or issues
|
||||
|
||||
test("the four tiles report the window, and each links where its number leads", async () => {
|
||||
renderApp();
|
||||
const tiles = within((await screen.findByText("1,000")).closest("dl") as HTMLElement);
|
||||
await screen.findAllByText("1,000");
|
||||
const tiles = within(screen.getByRole("list", { name: "Totals" }));
|
||||
expect(tiles.getByText("1,000")).toBeTruthy();
|
||||
expect(tiles.getByText("250")).toBeTruthy();
|
||||
expect(tiles.getByText("25.0%")).toBeTruthy();
|
||||
expect(tiles.getByText("25.00%")).toBeTruthy();
|
||||
expect(tiles.getByText("7")).toBeTruthy();
|
||||
expect(tiles.getByText("2.3 ms")).toBeTruthy();
|
||||
expect(tiles.getAllByRole("listitem").map((item) => item.querySelector("p + p")?.textContent)).toEqual([
|
||||
"queries",
|
||||
"blocked queries",
|
||||
"active clients",
|
||||
"of queries blocked",
|
||||
]);
|
||||
|
||||
// The bounds are the ones the stats response returned, not ones computed here.
|
||||
const queries = new URLSearchParams(
|
||||
@@ -317,9 +347,23 @@ test("the four tiles report the window, and each links where its number leads",
|
||||
expect(screen.queryByRole("link", { name: /average/i })).toBeNull();
|
||||
});
|
||||
|
||||
test("the cache card reads the hit share off the buckets and the forwarded count off the routes", async () => {
|
||||
renderApp();
|
||||
await screen.findAllByText("1,000");
|
||||
|
||||
const cache = within(panel("Cache hit rate"));
|
||||
// 10 cached answers of 1,000 queries; 500 + 100 went to an upstream.
|
||||
expect(cache.getByText("1.00%")).toBeTruthy();
|
||||
expect(cache.getByRole("img", { name: "1.00% of queries served from cache" })).toBeTruthy();
|
||||
expect(cache.getByText("10")).toBeTruthy();
|
||||
expect(cache.getByText("600")).toBeTruthy();
|
||||
expect(cache.getByText("2.3 ms")).toBeTruthy();
|
||||
expect(cache.getByText("of queries answered from cache")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("both donuts name every entry, nulls included, and disambiguate a nameless source", async () => {
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
await screen.findAllByText("1,000");
|
||||
|
||||
const types = within(panel("Query types"));
|
||||
expect(types.getByRole("rowheader", { name: "A" })).toBeTruthy();
|
||||
@@ -337,7 +381,7 @@ test("both donuts name every entry, nulls included, and disambiguate a nameless
|
||||
|
||||
test("the donut ring is decoration; the legend and the hidden table are the accessible surface", async () => {
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
await screen.findAllByText("1,000");
|
||||
|
||||
const svg = panel("Query types").querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
@@ -347,54 +391,110 @@ test("the donut ring is decoration; the legend and the hidden table are the acce
|
||||
|
||||
test("an empty window says so in every panel instead of drawing nothing", async () => {
|
||||
renderApp("/overview?period=1h");
|
||||
await screen.findByText("12");
|
||||
await screen.findAllByText("12");
|
||||
|
||||
// The two donuts and the client chart; the query-volume chart says it too.
|
||||
expect(screen.getAllByText("No queries in this period.").length).toBe(4);
|
||||
});
|
||||
|
||||
test("a deep link opens on the period it names", async () => {
|
||||
test("a deep link opens on the period it names, and the picker offers the four in words", async () => {
|
||||
renderApp("/overview?period=1h");
|
||||
await screen.findByText("12");
|
||||
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
|
||||
await screen.findAllByText("12");
|
||||
expect(periodTrigger().textContent).toContain("Last hour");
|
||||
|
||||
open(periodTrigger());
|
||||
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
|
||||
"Last hour",
|
||||
"Last 24 hours",
|
||||
"Last 7 days",
|
||||
"Last 30 days",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
|
||||
const router = renderApp("/overview?period=90d");
|
||||
await screen.findByText("1,000");
|
||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true");
|
||||
await screen.findAllByText("1,000");
|
||||
expect(periodTrigger().textContent).toContain("Last 24 hours");
|
||||
expect(router.state.location.search).toEqual({});
|
||||
});
|
||||
|
||||
test("the picker rescopes every panel and writes the period into the url", async () => {
|
||||
const router = renderApp();
|
||||
await screen.findByText("1,000");
|
||||
await screen.findAllByText("1,000");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
||||
open(periodTrigger());
|
||||
fireEvent.click(screen.getByRole("option", { name: "Last hour" }));
|
||||
|
||||
await screen.findByText("12");
|
||||
await screen.findAllByText("12");
|
||||
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
|
||||
// No panel is left describing the period the reader left.
|
||||
expect(screen.queryByText("1,000")).toBeNull();
|
||||
});
|
||||
|
||||
test("one failing panel keeps its own error and leaves the rest of the page standing", async () => {
|
||||
failing.add("/api/stats/routes");
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
test("a deep link to one device scopes the request, the tiles' links and the picker", async () => {
|
||||
renderApp("/overview?client=192.0.2.31");
|
||||
await screen.findAllByText("1,000");
|
||||
|
||||
await waitFor(() => expect(within(panel("Upstream servers")).getByText("endpoint unavailable")).toBeTruthy());
|
||||
expect(within(panel("Upstream servers")).getByRole("button", { name: "Retry" })).toBeTruthy();
|
||||
// A failed donut never blanks the charts.
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: /client activity over time/i })).toBeTruthy();
|
||||
expect(requested("client=192.0.2.31")).toBe(true);
|
||||
// Not registered, so the picker shows the address rather than claiming the household.
|
||||
expect(deviceTrigger().textContent).toContain("192.0.2.31");
|
||||
const queries = new URLSearchParams(
|
||||
screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "",
|
||||
);
|
||||
expect(queries.get("client")).toBe("192.0.2.31");
|
||||
});
|
||||
|
||||
test("the device picker names the registered clients and writes the choice into the url", async () => {
|
||||
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
|
||||
const router = renderApp();
|
||||
await screen.findAllByText("1,000");
|
||||
expect(deviceTrigger().textContent).toContain("All devices");
|
||||
|
||||
// The clients list has landed once the chart names the client by it.
|
||||
await waitFor(() => expect(within(panel("Client activity over time")).getAllByText("kitchen-pi")).toHaveLength(2));
|
||||
open(deviceTrigger());
|
||||
fireEvent.click(screen.getByRole("option", { name: "kitchen-pi" }));
|
||||
|
||||
await waitFor(() => expect(router.state.location.search).toEqual({ client: "192.0.2.30" }));
|
||||
await waitFor(() => expect(requested("client=192.0.2.30")).toBe(true));
|
||||
expect(deviceTrigger().textContent).toContain("kitchen-pi");
|
||||
|
||||
// Back to the household drops the parameter rather than writing an empty one.
|
||||
open(deviceTrigger());
|
||||
fireEvent.click(screen.getByRole("option", { name: "All devices" }));
|
||||
await waitFor(() => expect(router.state.location.search).toEqual({}));
|
||||
});
|
||||
|
||||
test("a client list in the url is not this page's grammar and is dropped", async () => {
|
||||
const router = renderApp("/overview?client=192.0.2.30,192.0.2.31");
|
||||
await screen.findAllByText("1,000");
|
||||
expect(router.state.location.search).toEqual({});
|
||||
expect(requested("client=")).toBe(false);
|
||||
});
|
||||
|
||||
test("a failed request is one error for the whole page, stated once and retryable", async () => {
|
||||
failing = true;
|
||||
renderApp();
|
||||
|
||||
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(periodTrigger()).toBeTruthy();
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
|
||||
failing = false;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await screen.findAllByText("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 () => {
|
||||
coverageComplete = false;
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
await screen.findAllByText("1,000");
|
||||
expect(screen.getAllByText(/Query history is available from/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1,42 +1,34 @@
|
||||
/**
|
||||
* Overview: what the resolver did over a period the reader chooses, in the
|
||||
* layout Pi-hole's dashboard established — four totals, two full-width charts,
|
||||
* two breakdown donuts. Nothing on this page is a current-state readout; the
|
||||
* five health conditions live on Diagnostics, and protection lives in the
|
||||
* sidebar beside its control.
|
||||
* Overview: what the resolver did over a period the reader chooses, for the
|
||||
* household or for one device, in the layout the decision record settled
|
||||
* (ui-visual-redesign.md) — four totals, two full-width charts, then the cache
|
||||
* rate and the two breakdown rings in a row of cards. Nothing on this page is a
|
||||
* current-state readout; the five health conditions live on Diagnostics, and
|
||||
* protection lives in the sidebar beside its control.
|
||||
*
|
||||
* The period is URL state, so a view is a link: `/overview?period=1h` opens
|
||||
* exactly what the sender was reading.
|
||||
* The scope is URL state, so a view is a link: `/overview?period=1h&client=…`
|
||||
* 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";
|
||||
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 { colors } from "@/ui/tokens.stylex";
|
||||
import type { Overview, OverviewRouteRow, OverviewTypeRow } from "@/lib/types";
|
||||
import Card from "@/ui/Card";
|
||||
import CacheCard from "./CacheCard";
|
||||
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, useOverviewScope } from "./OverviewFrame";
|
||||
import { useOverviewWindow, type Panel } from "./overviewWindow";
|
||||
import { DEFAULT_PERIOD, PERIODS } from "./period";
|
||||
import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
|
||||
|
||||
/**
|
||||
* Where the two donuts stop competing for width and sit side by side. `Donut`
|
||||
* carries the same query for the alignment it switches at that width; StyleX
|
||||
* requires the string to be a literal in the module that uses it, so the two
|
||||
* agree by inspection rather than by sharing a constant.
|
||||
*/
|
||||
const TWO_COLUMN = "@media (min-width: 1280px)";
|
||||
import { qtypeKey, routeKey, seriesColor, typeRampColor } from "./seriesColors";
|
||||
|
||||
const ROUTE_LABELS = {
|
||||
blocked: "Blocked",
|
||||
@@ -48,120 +40,31 @@ 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,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
panelHeading: {
|
||||
marginBottom: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
donutRow: {
|
||||
/** As many cards per row as fit at 20rem each: three on a desktop, one on a phone. */
|
||||
cards: {
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: { default: "minmax(0, 1fr)", [TWO_COLUMN]: "repeat(2, minmax(0, 1fr))" },
|
||||
},
|
||||
loading: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
gap: "1.25rem",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(20rem, 1fr))",
|
||||
},
|
||||
});
|
||||
|
||||
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 toolbar 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) => ({
|
||||
/** The API ranks the types by count, so the ramp's darkest step is the busiest type. */
|
||||
function typeSlices(types: OverviewTypeRow[]): DonutSlice[] {
|
||||
return types.map((row, rank) => ({
|
||||
key: qtypeKey(row.qtype),
|
||||
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
|
||||
value: row.count,
|
||||
color: seriesColor(qtypeKey(row.qtype)),
|
||||
color: typeRampColor(rank),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -171,8 +74,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),
|
||||
@@ -184,65 +87,71 @@ function routeSlices(data: StatsRoutes): DonutSlice[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** What the cache card reads: hits are the buckets' cached counts, forwarded is every upstream or zone answer. */
|
||||
function cacheOf(data: Overview) {
|
||||
return {
|
||||
queries: data.totals.queries,
|
||||
hits: data.buckets.reduce((sum, bucket) => sum + bucket.cached, 0),
|
||||
forwarded: data.routes
|
||||
.filter((row) => row.route === "upstream" || row.route === "forward_zone")
|
||||
.reduce((sum, row) => sum + row.count, 0),
|
||||
avg_response_time_us: data.totals.avg_response_time_us,
|
||||
};
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
|
||||
const navigate = useNavigate({ from: "/overview" });
|
||||
const overview = useOverviewWindow(period);
|
||||
const [scope, setScope] = useOverviewScope();
|
||||
const overview = useOverviewWindow(scope.period, scope.client);
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.page)}>
|
||||
<div {...stylex.props(styles.headingRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Overview</h1>
|
||||
<PeriodPicker
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PanelBody panel={overview.totals}>{(totals) => <StatTiles stats={totals} />}</PanelBody>
|
||||
|
||||
{/* One notice for the page: every panel is judged against the same window,
|
||||
so a second copy would only repeat this sentence. */}
|
||||
{overview.coverage !== null && <CoverageNotice coverage={overview.coverage} />}
|
||||
|
||||
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
|
||||
Queries over time
|
||||
</h2>
|
||||
<PanelBody panel={overview.timeseries}>{(data) => <TimeseriesChart data={data} />}</PanelBody>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
|
||||
Client activity over time
|
||||
</h2>
|
||||
<PanelBody panel={overview.clients}>{(data) => <ClientChart data={data} />}</PanelBody>
|
||||
</section>
|
||||
|
||||
<div {...stylex.props(styles.donutRow)}>
|
||||
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
|
||||
Query types
|
||||
</h2>
|
||||
<PanelBody panel={overview.types}>
|
||||
{(data) => <Donut slices={typeSlices(data)} caption="Queries by DNS type" unit="Queries" />}
|
||||
</PanelBody>
|
||||
</section>
|
||||
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
|
||||
Upstream servers
|
||||
</h2>
|
||||
<PanelBody panel={overview.routes}>
|
||||
<OverviewFrame scope={scope} onChange={setScope}>
|
||||
<PageBody panel={overview}>
|
||||
{(data) => (
|
||||
<>
|
||||
<StatTiles
|
||||
stats={{ since: data.since, until: data.until, client: scope.client, ...data.totals }}
|
||||
/>
|
||||
|
||||
{/* 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} />
|
||||
|
||||
<Card
|
||||
title="Queries over time"
|
||||
description="Every query the resolver answered in this period, with the blocked share along the bottom."
|
||||
>
|
||||
<TimeseriesChart data={data} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Client activity over time"
|
||||
description="Which devices made the queries, stacked per bucket."
|
||||
>
|
||||
<ClientChart data={data} />
|
||||
</Card>
|
||||
|
||||
<div {...stylex.props(styles.cards)}>
|
||||
<CacheCard data={cacheOf(data)} />
|
||||
<Card
|
||||
title="Query types"
|
||||
description="The record types clients asked for across this period."
|
||||
>
|
||||
<Donut slices={typeSlices(data.types)} caption="Queries by DNS type" unit="Queries" />
|
||||
</Card>
|
||||
<Card
|
||||
title="Upstream servers"
|
||||
description="How each query was answered: by which resolver, from cache, or not at all."
|
||||
>
|
||||
<Donut
|
||||
slices={routeSlices(data)}
|
||||
slices={routeSlices(data.routes)}
|
||||
caption="Queries by how they were answered"
|
||||
unit="Queries"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PanelBody>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</OverviewFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,120 +1,141 @@
|
||||
/**
|
||||
* The window's four headline numbers, each with the way into the rows behind it.
|
||||
* The window's four headline numbers (ui-visual-redesign.md): centred 2.5rem
|
||||
* numerals over lowercase captions, each with the way into the rows behind it.
|
||||
* Colour is semantic and nothing else — the blocked count is red, the share is
|
||||
* muted, the rest is ink — so a tile never implies a state it is not reporting.
|
||||
*
|
||||
* Neutral chrome throughout: no coloured accents, no per-tile tone. Emphasis is
|
||||
* typographic, so the eye ranks the figures rather than the panels, and a tile
|
||||
* never implies a state it is not reporting.
|
||||
* On a phone the four tiles merge into one card: the three counts side by side
|
||||
* and the share on a line under them, so the set fits above the fold.
|
||||
*
|
||||
* 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.
|
||||
* slightly different span than the one they were just reading — and the client
|
||||
* scope the page is under, so the rows they open are the rows the tile counted.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { formatMicros } from "@/lib/format";
|
||||
import type { StatsTotals } from "@/lib/types";
|
||||
import { formatCount, formatPercent } from "@/lib/format";
|
||||
import type { OverviewTotals } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
const NARROW = "@media (max-width: 800px)";
|
||||
|
||||
const styles = stylex.create({
|
||||
/** Two columns on a phone, the whole set of four in one row from `md`. */
|
||||
grid: {
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 768px)": "repeat(4, minmax(0, 1fr))",
|
||||
},
|
||||
gap: { default: "1rem", [NARROW]: 0 },
|
||||
gridTemplateColumns: { default: "repeat(4, minmax(0, 1fr))", [NARROW]: "repeat(3, minmax(0, 1fr))" },
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyleType: "none",
|
||||
borderRadius: { default: null, [NARROW]: metrics.radius },
|
||||
borderWidth: { default: 0, [NARROW]: 1 },
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: { default: null, [NARROW]: colors.surfaceRaised },
|
||||
paddingInline: { default: 0, [NARROW]: "0.5rem" },
|
||||
paddingBlock: { default: 0, [NARROW]: "1rem" },
|
||||
},
|
||||
tile: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.125rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
minWidth: 0,
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: { default: 1, [NARROW]: 0 },
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
paddingInline: { default: "1rem", [NARROW]: "0.25rem" },
|
||||
paddingBlock: { default: "1.5rem", [NARROW]: 0 },
|
||||
textAlign: "center",
|
||||
},
|
||||
label: {
|
||||
/** The share drops under the three counts on a phone, divided from them by a hairline. */
|
||||
shareTile: {
|
||||
gridColumn: { default: null, [NARROW]: "1 / -1" },
|
||||
marginTop: { default: 0, [NARROW]: "1rem" },
|
||||
paddingTop: { default: null, [NARROW]: "1rem" },
|
||||
borderTopWidth: { default: null, [NARROW]: 1 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
value: {
|
||||
margin: 0,
|
||||
fontSize: { default: "2.5rem", [NARROW]: "1.75rem" },
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
letterSpacing: "-0.02em",
|
||||
color: colors.text,
|
||||
},
|
||||
valueBlocked: { color: colors.chartRed },
|
||||
valueMuted: { color: colors.textMuted },
|
||||
caption: {
|
||||
margin: 0,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
valueRow: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
value: {
|
||||
fontSize: "1.875rem",
|
||||
lineHeight: "2.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
detail: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
footer: {
|
||||
link: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
link: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
});
|
||||
|
||||
function percentOf(part: number, total: number): string | null {
|
||||
if (total === 0) return null;
|
||||
return `${((part / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function Tile({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
footer,
|
||||
caption,
|
||||
tone,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string | null;
|
||||
footer?: React.ReactNode;
|
||||
caption: string;
|
||||
tone?: "blocked" | "muted";
|
||||
style?: stylex.StyleXStyles;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div {...stylex.props(styles.tile)}>
|
||||
<dt {...stylex.props(styles.label)}>{label}</dt>
|
||||
<dd {...stylex.props(styles.valueRow)}>
|
||||
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
|
||||
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
|
||||
</dd>
|
||||
{footer !== undefined && <div {...stylex.props(styles.footer)}>{footer}</div>}
|
||||
</div>
|
||||
<li {...stylex.props(styles.tile, style)}>
|
||||
<p
|
||||
{...stylex.props(
|
||||
styles.value,
|
||||
shared.tabularNums,
|
||||
tone === "blocked" && styles.valueBlocked,
|
||||
tone === "muted" && styles.valueMuted,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p {...stylex.props(styles.caption)}>{caption}</p>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatTiles({ stats }: { stats: StatsTotals }) {
|
||||
/** The window's totals with the bounds they were measured over and the scope they were read under. */
|
||||
export interface StatTilesData extends OverviewTotals {
|
||||
since: number;
|
||||
until: number;
|
||||
client: string | undefined;
|
||||
}
|
||||
|
||||
export default function StatTiles({ stats }: { stats: StatTilesData }) {
|
||||
const window = {
|
||||
mode: "history" as const,
|
||||
since: stats.since,
|
||||
until: stats.until,
|
||||
domain: undefined,
|
||||
client: undefined,
|
||||
client: stats.client,
|
||||
};
|
||||
return (
|
||||
<dl {...stylex.props(styles.grid)}>
|
||||
<Tile
|
||||
label="Queries"
|
||||
value={numberFormat.format(stats.queries)}
|
||||
footer={
|
||||
<ul aria-label="Totals" {...stylex.props(styles.grid)}>
|
||||
<Tile value={formatCount(stats.queries)} caption="queries">
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ ...window, blocked: undefined }}
|
||||
@@ -122,13 +143,8 @@ export default function StatTiles({ stats }: { stats: StatsTotals }) {
|
||||
>
|
||||
Open in Activity
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
label="Blocked"
|
||||
value={numberFormat.format(stats.blocked)}
|
||||
detail={percentOf(stats.blocked, stats.queries)}
|
||||
footer={
|
||||
</Tile>
|
||||
<Tile value={formatCount(stats.blocked)} caption="blocked queries" tone="blocked">
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ ...window, blocked: true }}
|
||||
@@ -136,21 +152,18 @@ export default function StatTiles({ stats }: { stats: StatsTotals }) {
|
||||
>
|
||||
Open blocked queries
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
label="Clients"
|
||||
value={numberFormat.format(stats.clients)}
|
||||
footer={
|
||||
</Tile>
|
||||
<Tile value={formatCount(stats.clients)} caption="active clients">
|
||||
<Link to="/clients" {...stylex.props(styles.link, shared.focusRing)}>
|
||||
Manage clients
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
<Tile
|
||||
label="Avg response"
|
||||
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
|
||||
value={stats.queries === 0 ? "—" : formatPercent(stats.blocked / stats.queries)}
|
||||
caption="of queries blocked"
|
||||
tone="muted"
|
||||
style={styles.shareTile}
|
||||
/>
|
||||
</dl>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
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 { CHART_BLUE, CHART_RED } from "./seriesColors";
|
||||
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) => ({
|
||||
/** The fallback width less the margins: where the plot starts and ends. */
|
||||
const PLOT_LEFT = 44;
|
||||
const PLOT_RIGHT = 44 + 588;
|
||||
const PLOT_TOP = 8;
|
||||
const PLOT_BOTTOM = 240 - 22;
|
||||
|
||||
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 +36,25 @@ 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"]'));
|
||||
}
|
||||
|
||||
/** The area fill and the line of one series. */
|
||||
function seriesPaths(container: HTMLElement, key: string): { area: SVGPathElement; line: SVGPathElement } {
|
||||
const group = container.querySelector(`g[data-series="${key}"]`) as SVGGElement;
|
||||
const [area, line] = Array.from(group.querySelectorAll("path"));
|
||||
return { area, line };
|
||||
}
|
||||
|
||||
/** Every coordinate pair in a path, in drawing order. */
|
||||
function pathPoints(d: string): [number, number][] {
|
||||
return Array.from(d.matchAll(/(-?[\d.]+),(-?[\d.]+)/g)).map((match) => [Number(match[1]), Number(match[2])]);
|
||||
}
|
||||
|
||||
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 +68,405 @@ 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 two counts come
|
||||
* from separate columns that a partial write can leave inconsistent. A negative
|
||||
* remainder would be a lie in the table.
|
||||
*/
|
||||
test("allowed is the remainder of the reported total, clamped at zero", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 12, cached: 5 }])} />,
|
||||
);
|
||||
|
||||
const row = within(screen.getByRole("table")).getAllByRole("row")[1];
|
||||
expect(
|
||||
within(row)
|
||||
.getAllByRole("cell")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["10", "12", "0"]);
|
||||
|
||||
// The scale comes from the reported total, so the plot's 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();
|
||||
});
|
||||
|
||||
/**
|
||||
* Two series, each a line over its own fill, in the two colours the decision
|
||||
* record fixed: the accent blue for the total, the softer red for blocked. The
|
||||
* total is drawn first so the blocked band paints over its faint fill.
|
||||
*/
|
||||
test("each series is a line over a fill in its own colour, blocked painted over the total", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 100, blocked: 40, cached: 10 }])} />,
|
||||
);
|
||||
|
||||
const groups = Array.from(container.querySelectorAll("g[data-series]")).map((g) => g.getAttribute("data-series"));
|
||||
expect(groups).toEqual(["queries", "blocked"]);
|
||||
|
||||
const total = seriesPaths(container, "queries");
|
||||
expect(total.area.getAttribute("fill")).toBe(CHART_BLUE);
|
||||
expect(total.area.getAttribute("fill-opacity")).toBe("0.13");
|
||||
expect(total.line.getAttribute("stroke")).toBe(CHART_BLUE);
|
||||
expect(total.line.getAttribute("stroke-width")).toBe("2");
|
||||
expect(total.line.getAttribute("fill")).toBe("none");
|
||||
|
||||
const blocked = seriesPaths(container, "blocked");
|
||||
expect(blocked.area.getAttribute("fill")).toBe(CHART_RED);
|
||||
expect(blocked.area.getAttribute("fill-opacity")).toBe("0.35");
|
||||
expect(blocked.line.getAttribute("stroke")).toBe(CHART_RED);
|
||||
});
|
||||
|
||||
/**
|
||||
* The owner could not see past the last labelled point when the points sat at
|
||||
* band centres: the first bucket is on the plot's left edge and the last on its
|
||||
* right, so the curve covers the whole plot and ends on its data.
|
||||
*/
|
||||
test("the points run edge to edge, and the curve passes through every one of them", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 10, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 5, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 3600, queries: 10, blocked: 0, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
const points = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "");
|
||||
// M, then two Cs of three pairs each: the last pair of each C is the data point.
|
||||
expect(points).toHaveLength(7);
|
||||
expect(points[0]).toEqual([PLOT_LEFT, PLOT_TOP]);
|
||||
expect(points[3][0]).toBeCloseTo((PLOT_LEFT + PLOT_RIGHT) / 2, 1);
|
||||
expect(points[6]).toEqual([PLOT_RIGHT, PLOT_TOP]);
|
||||
// The fill closes down to the baseline under the same two ends.
|
||||
expect(seriesPaths(container, "queries").area.getAttribute("d")).toMatch(
|
||||
new RegExp(` L${PLOT_RIGHT},${PLOT_BOTTOM} L${PLOT_LEFT},${PLOT_BOTTOM} Z$`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A spline through a spike overshoots. Clamping the control points keeps the
|
||||
* curve inside the plot, so a zero bucket beside a busy one never dips below
|
||||
* the baseline or above the top.
|
||||
*/
|
||||
test("the curve never leaves the plot, whatever the data does", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 0, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 100, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 3600, queries: 0, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 5400, queries: 0, blocked: 0, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const [, y] of pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "")) {
|
||||
expect(y).toBeGreaterThanOrEqual(PLOT_TOP);
|
||||
expect(y).toBeLessThanOrEqual(PLOT_BOTTOM);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 low point. */
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The bands tile the plot with their boundaries midway between neighbouring
|
||||
* points, so landing anywhere in the plot is already "snap to the nearest
|
||||
* bucket" and no pixel belongs to no bucket.
|
||||
*/
|
||||
test("the hit targets tile the plot edge to edge, split midway between points", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
const edges = overlayRects(container).map((rect) => {
|
||||
const x = Number(rect.getAttribute("x"));
|
||||
return [x, x + Number(rect.getAttribute("width"))];
|
||||
});
|
||||
const middle = (PLOT_LEFT + PLOT_RIGHT) / 2;
|
||||
expect(edges[0][0]).toBe(PLOT_LEFT);
|
||||
expect(edges[0][1]).toBeCloseTo((PLOT_LEFT + middle) / 2, 6);
|
||||
expect(edges[1][0]).toBeCloseTo(edges[0][1], 6);
|
||||
expect(edges[2][1]).toBe(PLOT_RIGHT);
|
||||
});
|
||||
|
||||
/**
|
||||
* The owner's ask: a dot on each curve and a guide line, so the reader can see
|
||||
* which point the tooltip describes. The dots sit at the coordinates the curves
|
||||
* were built from, ringed in the panel's surface so they read over either fill.
|
||||
*/
|
||||
test("pointing at a bucket marks it with a guide line and a dot on each curve", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("g[data-hover]")).toBeNull();
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[1]);
|
||||
|
||||
const hover = container.querySelector("g[data-hover]") as SVGGElement;
|
||||
const guide = hover.querySelector("line") as SVGLineElement;
|
||||
expect(guide.getAttribute("x1")).toBe(String(PLOT_RIGHT));
|
||||
expect(guide.getAttribute("y1")).toBe(String(PLOT_TOP));
|
||||
expect(guide.getAttribute("y2")).toBe(String(PLOT_BOTTOM));
|
||||
|
||||
const dots = Array.from(hover.querySelectorAll("circle"));
|
||||
expect(dots.map((dot) => dot.getAttribute("fill"))).toEqual([CHART_BLUE, CHART_RED]);
|
||||
const lastPoint = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "").at(-1);
|
||||
expect(Number(dots[0].getAttribute("cx"))).toBe(PLOT_RIGHT);
|
||||
expect(Number(dots[0].getAttribute("cy"))).toBeCloseTo(lastPoint?.[1] ?? NaN, 1);
|
||||
for (const dot of dots) expect(dot.getAttribute("r")).toBe("4");
|
||||
});
|
||||
|
||||
test("pointing at a bucket names the total, the blocked share and the remainder", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 1000, blocked: 400, 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 terms = Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent);
|
||||
expect(terms).toEqual(["Total queries", "Blocked", "Allowed"]);
|
||||
const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
|
||||
expect(values).toEqual(["1,000", "400", "600"]);
|
||||
// One swatch per drawn series; the remainder is arithmetic, not a shape.
|
||||
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
|
||||
expect(swatches).toHaveLength(2);
|
||||
expect(swatches[0]).toContain(CHART_BLUE);
|
||||
expect(swatches[1]).toContain(CHART_RED);
|
||||
});
|
||||
|
||||
/**
|
||||
* Beside the point, never over it: 16px to its right at the point's own height,
|
||||
* so the box hides neither the dot nor the slot of data the reader is reading.
|
||||
* `TooltipWithBounds` flips it to the left when it would run off the right edge.
|
||||
*/
|
||||
test("the tooltip stands 16px beside the point, at the point's own height", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(2)} />);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
|
||||
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
const firstPoint = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "")[0];
|
||||
expect(tooltip.style.transform).toBe(`translate(${PLOT_LEFT + 16}px, ${Math.round(firstPoint[1])}px)`);
|
||||
});
|
||||
|
||||
/**
|
||||
* `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", "60"]);
|
||||
|
||||
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", "60"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 and the
|
||||
* marks go away rather than describing a bucket that is no longer drawn.
|
||||
* Rolling back to the earlier window must not bring them back either: the
|
||||
* selection is deleted when the window moves, not held aside.
|
||||
*/
|
||||
test("a refresh that rolls the window takes the tooltip and the marks down", () => {
|
||||
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);
|
||||
expect(container.querySelector("g[data-hover]")).not.toBeNull();
|
||||
|
||||
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);
|
||||
expect(container.querySelector("g[data-hover]")).toBeNull();
|
||||
|
||||
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 — 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 marks 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);
|
||||
expect(container.querySelector("g[data-hover]")).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The legend names the two drawn curves and nothing else; the table adds the
|
||||
* remainder, because a reader of the table has no fill to read it off.
|
||||
*/
|
||||
test("the legend names the two curves, and the table adds the remainder", () => {
|
||||
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(["Total queries", "Blocked"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "Queries", "Blocked", "Allowed"]);
|
||||
expect(screen.queryByText("Other")).toBeNull();
|
||||
expect(screen.queryByText("Cached")).toBeNull();
|
||||
});
|
||||
|
||||
test("the hidden table groups its counts like every other figure on the page", () => {
|
||||
render(<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 12345, blocked: 1234, cached: 1 }])} />);
|
||||
|
||||
const row = within(screen.getByRole("table")).getAllByRole("row")[1];
|
||||
expect(
|
||||
within(row)
|
||||
.getAllByRole("cell")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["12,345", "1,234", "11,111"]);
|
||||
});
|
||||
|
||||
@@ -1,297 +1,199 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
/**
|
||||
* Query volume over the window as a smoothed area: every query in blue, the
|
||||
* blocked share in red along the bottom (ui-visual-redesign.md). Pointing at
|
||||
* the plot selects the nearest bucket and marks it with a guide line and a dot
|
||||
* on each curve; the tooltip sits beside the point so it never covers the slot
|
||||
* the reader is looking at.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { StatsTimeseries } from "@/lib/types";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
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 {
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartLegend,
|
||||
ChartRoot,
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
HitBands,
|
||||
areaPath,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
pointScale,
|
||||
smoothPath,
|
||||
useActiveIndex,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
valueTicks,
|
||||
type TooltipContent,
|
||||
} from "./chartKit";
|
||||
import { CHART_BLUE, CHART_RED } from "./seriesColors";
|
||||
|
||||
// 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).
|
||||
/**
|
||||
* Drawn bottom-up in this order: the total's fill is faint so the blocked
|
||||
* curve, painted over it, stays a solid band along the baseline.
|
||||
*/
|
||||
const SERIES = [
|
||||
{ key: "blocked", label: "Blocked", color: "#ef4444" },
|
||||
{ key: "cached", label: "Cached", color: "#059669" },
|
||||
{ key: "other", label: "Other", color: "#3b82f6" },
|
||||
{ key: "queries", label: "Total queries", color: CHART_BLUE, fillOpacity: 0.13 },
|
||||
{ key: "blocked", label: "Blocked", color: CHART_RED, fillOpacity: 0.35 },
|
||||
] as const;
|
||||
|
||||
const CHART_HEIGHT = 240;
|
||||
const FALLBACK_WIDTH = 640;
|
||||
type SeriesKey = (typeof SERIES)[number]["key"];
|
||||
|
||||
const LINE_WIDTH = 2;
|
||||
const DOT_RADIUS = 4;
|
||||
|
||||
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: {
|
||||
guide: {
|
||||
stroke: colors.borderStrong,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
legend: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
columnGap: "1rem",
|
||||
rowGap: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
legendItem: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
/** The dot's ring is the panel's own surface, so it stays legible over either fill. */
|
||||
dot: {
|
||||
stroke: colors.surfaceRaised,
|
||||
strokeWidth: 2,
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
/** queries - blocked, clamped at 0. */
|
||||
allowed: 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,
|
||||
allowed: Math.max(0, bucket.queries - bucket.blocked),
|
||||
}));
|
||||
}
|
||||
|
||||
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: [
|
||||
...SERIES.map((series) => ({
|
||||
key: series.key,
|
||||
label: series.label,
|
||||
color: series.color,
|
||||
value: formatCount(column[series.key]),
|
||||
})),
|
||||
{ key: "allowed", label: "Allowed", value: formatCount(column.allowed) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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 peak = Math.max(...columns.map((column) => column.queries));
|
||||
const plot = plotArea(width, peak);
|
||||
const xScale = pointScale(timestamps, plot);
|
||||
const yScale = valueScale(peak, [plot.bottom, plot.y]);
|
||||
const yTicks = valueTicks(yScale);
|
||||
const centers = timestamps.map((ts) => xScale(ts) ?? plot.x);
|
||||
const pointsOf = (key: SeriesKey): [number, number][] =>
|
||||
columns.map((column, i) => [centers[i], yScale(column[key])]);
|
||||
|
||||
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, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onMouseLeave={hovered.clear}
|
||||
>
|
||||
{layout.yTicks.map((tick) => (
|
||||
<g key={tick.value}>
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
{...stylex.props(styles.gridLine)}
|
||||
<ChartFrame
|
||||
plot={plot}
|
||||
yScale={yScale}
|
||||
yTicks={yTicks}
|
||||
xScale={xScale}
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
>
|
||||
{compact.format(tick.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={baseline}
|
||||
y2={baseline}
|
||||
{...stylex.props(styles.axisLine)}
|
||||
/>
|
||||
{layout.xTicks.map((tick) => (
|
||||
<text
|
||||
key={tick.ts}
|
||||
x={tick.x}
|
||||
y={baseline + 14}
|
||||
textAnchor="middle"
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
>
|
||||
{formatTick(tick.ts, data.bucket_seconds)}
|
||||
</text>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<g key={bar.bucket.ts} opacity={hovered === null || hovered === i ? 1 : 0.55}>
|
||||
{SERIES.map((series) => {
|
||||
const rect = bar.segments[series.key];
|
||||
if (rect.height <= 0) return null;
|
||||
const points = pointsOf(series.key);
|
||||
const line = smoothPath(points, plot.y, plot.bottom);
|
||||
return (
|
||||
<rect
|
||||
key={series.key}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
<g key={series.key} data-series={series.key}>
|
||||
<path
|
||||
d={areaPath(line, points, plot)}
|
||||
fill={series.color}
|
||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
fillOpacity={series.fillOpacity}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={series.color}
|
||||
strokeWidth={LINE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<rect
|
||||
key={bar.bucket.ts}
|
||||
x={bar.slot.x}
|
||||
y={bar.slot.y}
|
||||
width={bar.slot.width}
|
||||
height={bar.slot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
>
|
||||
<title>{barSummary(bar)}</title>
|
||||
</rect>
|
||||
))}
|
||||
</svg>
|
||||
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{hovered.index !== null && (
|
||||
<g data-hover="">
|
||||
<line
|
||||
x1={centers[hovered.index]}
|
||||
x2={centers[hovered.index]}
|
||||
y1={plot.y}
|
||||
y2={plot.bottom}
|
||||
{...stylex.props(styles.guide)}
|
||||
/>
|
||||
{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))}
|
||||
<circle
|
||||
key={series.key}
|
||||
cx={centers[hovered.index as number]}
|
||||
cy={yScale(columns[hovered.index as number][series.key])}
|
||||
r={DOT_RADIUS}
|
||||
fill={series.color}
|
||||
{...stylex.props(styles.dot)}
|
||||
/>
|
||||
{series.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</g>
|
||||
)}
|
||||
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(columns[hovered.index])}
|
||||
left={centers[hovered.index]}
|
||||
top={yScale(columns[hovered.index].queries)}
|
||||
beside
|
||||
/>
|
||||
)}
|
||||
<ChartLegend
|
||||
entries={SERIES.map((series) => ({ key: series.key, label: series.label, color: series.color }))}
|
||||
/>
|
||||
<div {...stylex.props(shared.srOnly)}>
|
||||
<table>
|
||||
<caption>Queries per time bucket</caption>
|
||||
@@ -300,23 +202,21 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
||||
<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>
|
||||
<th scope="col">Allowed</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>{formatCount(column.queries)}</td>
|
||||
<td>{formatCount(column.blocked)}</td>
|
||||
<td>{formatCount(column.allowed)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</ChartRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import {
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartTooltip,
|
||||
MARGIN,
|
||||
TICK_GLYPH_PX,
|
||||
TICK_LABEL_GAP_PX,
|
||||
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]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A query count is a whole number. d3 answers a domain of [0, 1] with fifths,
|
||||
* and "0.5" queries is a quantity the data cannot hold, so the fractional
|
||||
* ticks are dropped from the grid and the axis alike.
|
||||
*/
|
||||
test("a single-digit peak is ticked in whole queries", () => {
|
||||
expect(valueTicks(valueScale(1, [240, 0]))).toEqual([0, 1]);
|
||||
expect(valueTicks(valueScale(2, [240, 0]))).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plotArea", () => {
|
||||
/**
|
||||
* The value labels are grouped counts, so a chart that peaks in the millions
|
||||
* needs more room to its left than the default margin: "1,250,000" does not
|
||||
* fit where "120" does. The width is measured on a quarter above the peak,
|
||||
* because the tick above it is the widest label drawn.
|
||||
*/
|
||||
test("the left margin grows with the widest value label", () => {
|
||||
expect(plotArea(640, 0).x).toBe(MARGIN.left);
|
||||
|
||||
const widest = formatCount(Math.ceil(1_000_000 * 1.25));
|
||||
expect(plotArea(640, 1_000_000).x).toBe(widest.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX);
|
||||
expect(plotArea(640, 1_000_000).x).toBeGreaterThan(MARGIN.left);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `TooltipWithBounds` writes its own inline `transform` to place itself, and it
|
||||
* decides whether to flip above the anchor from that same number. Centring a
|
||||
* beside tooltip therefore has to reach the library as an `offsetTop`, measured
|
||||
* from the box, and not as a CSS transform the library cannot see.
|
||||
*/
|
||||
describe("ChartTooltip", () => {
|
||||
const BOX_HEIGHT = 40;
|
||||
|
||||
function withMeasuredBox(run: () => void) {
|
||||
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: () => BOX_HEIGHT,
|
||||
});
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, "offsetHeight", original);
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTooltip(beside: boolean) {
|
||||
const { container } = render(
|
||||
<ChartTooltip
|
||||
content={{ title: "12:00", rows: [{ key: "queries", label: "Total queries", value: "7" }] }}
|
||||
index={0}
|
||||
left={100}
|
||||
top={100}
|
||||
beside={beside}
|
||||
/>,
|
||||
);
|
||||
return container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom measures nothing, so both rects are zero-sized: the library takes its
|
||||
* window branch, nothing is clipped at 100px into a 1024x768 window, and the
|
||||
* unflipped `translate(left + offsetLeft, top + offsetTop)` is what it writes.
|
||||
*/
|
||||
test("a beside tooltip is offset by half the height it measures", () => {
|
||||
withMeasuredBox(() => {
|
||||
expect(renderTooltip(true).style.transform).toBe(`translate(116px, ${100 - BOX_HEIGHT / 2}px)`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a hung tooltip clears its anchor by the corner offset", () => {
|
||||
withMeasuredBox(() => {
|
||||
expect(renderTooltip(false).style.transform).toBe("translate(108px, 108px)");
|
||||
});
|
||||
});
|
||||
|
||||
/** An unmeasured box is not centred at all, rather than centred on a guess. */
|
||||
test("no measurable height leaves the tooltip on the anchor", () => {
|
||||
expect(renderTooltip(true).style.transform).toBe("translate(116px, 100px)");
|
||||
});
|
||||
});
|
||||
|
||||
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,627 @@
|
||||
/**
|
||||
* The plumbing the charts on Overview share: the width measurement, the scales,
|
||||
* the axis and grid chrome, the smoothed path, the stacked segment, 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 { useLayoutEffect, 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, scalePoint } from "@visx/scale";
|
||||
import { TooltipWithBounds } from "@visx/tooltip";
|
||||
import { formatBucketTime, formatCount } from "@/lib/format";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, layers, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
export const CHART_HEIGHT = 240;
|
||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||
|
||||
/** The 10px tick label's glyph width; digits and the comma in a grouped count are all about this wide. */
|
||||
export const TICK_GLYPH_PX = 6;
|
||||
/** The tick label's gap to the axis (`dx` below) plus a little air. */
|
||||
export const TICK_LABEL_GAP_PX = 10;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plot inside the axes. The left margin grows with the widest y label the
|
||||
* chart can show: counts are thousands-grouped everywhere, ticks included, so
|
||||
* "120,000" needs the room "120" does not. The tick above `maxValue` can add a
|
||||
* digit or a comma, so the width is measured on a quarter more.
|
||||
*/
|
||||
export function plotArea(width: number, maxValue = 0): Plot {
|
||||
const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom);
|
||||
const label = formatCount(Math.ceil(maxValue * 1.25));
|
||||
const left = Math.max(MARGIN.left, label.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX);
|
||||
return {
|
||||
x: left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - left - MARGIN.right),
|
||||
height,
|
||||
bottom: MARGIN.top + height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The container's width, measured on mount and on every resize.
|
||||
*
|
||||
* `useLayoutEffect` rather than `useEffect`, because the difference is visible:
|
||||
* a layout effect measures and re-renders before the browser paints, so the
|
||||
* first painted frame is already the real width. Under `useEffect` the measured
|
||||
* width lands one painted frame later, and the reader sees a chart drawn at
|
||||
* `FALLBACK_WIDTH` snap to its container.
|
||||
*/
|
||||
export function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
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. A query count is a
|
||||
* whole number, so d3's fractional ticks — 0.5 of a query at a peak of 1 — are
|
||||
* dropped rather than labelled.
|
||||
*/
|
||||
export function valueTicks(scale: ValueScale): number[] {
|
||||
return scale.ticks(Y_TICK_COUNT).filter(Number.isInteger);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 of a bar chart: 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 time axis of the area chart: the first bucket on the plot's left edge and
|
||||
* the last on its right, so the curve never runs past its own data and the
|
||||
* reader can see the last point.
|
||||
*/
|
||||
export function pointScale(values: number[], plot: Plot) {
|
||||
return scalePoint<number>({ domain: values, range: [plot.x, plot.x + plot.width] });
|
||||
}
|
||||
|
||||
export type PointScale = ReturnType<typeof pointScale>;
|
||||
|
||||
export type TimeScale = BandScale | PointScale;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** One decimal of path precision: enough for a pixel, short enough to keep the DOM small. */
|
||||
function coordinate(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* A curve through every point, as cubic Béziers derived from a Catmull-Rom
|
||||
* spline. The control points' y is clamped to the plot, because a spline
|
||||
* through a spike overshoots and would otherwise draw the curve dipping below
|
||||
* the baseline beside a zero bucket. The curve passes exactly through each
|
||||
* point, so the hover dots sit at the coordinates the path was built from.
|
||||
*/
|
||||
export function smoothPath(points: [number, number][], top: number, bottom: number): string {
|
||||
if (points.length === 0) return "";
|
||||
const clamp = (y: number) => Math.max(top, Math.min(bottom, y));
|
||||
const at = (i: number) => points[Math.max(0, Math.min(points.length - 1, i))];
|
||||
let d = `M${coordinate(points[0][0])},${coordinate(points[0][1])}`;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const p0 = at(i - 1);
|
||||
const p1 = at(i);
|
||||
const p2 = at(i + 1);
|
||||
const p3 = at(i + 2);
|
||||
const c1x = p1[0] + (p2[0] - p0[0]) / 6;
|
||||
const c1y = clamp(p1[1] + (p2[1] - p0[1]) / 6);
|
||||
const c2x = p2[0] - (p3[0] - p1[0]) / 6;
|
||||
const c2y = clamp(p2[1] - (p3[1] - p1[1]) / 6);
|
||||
d += ` C${coordinate(c1x)},${coordinate(c1y)} ${coordinate(c2x)},${coordinate(c2y)} ${coordinate(p2[0])},${coordinate(p2[1])}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The same curve closed down to the baseline, for the fill under the line. */
|
||||
export function areaPath(line: string, points: [number, number][], plot: Plot): string {
|
||||
if (points.length === 0) return "";
|
||||
const first = coordinate(points[0][0]);
|
||||
const last = coordinate(points[points.length - 1][0]);
|
||||
return `${line} L${last},${plot.bottom} L${first},${plot.bottom} Z`;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: metrics.radius,
|
||||
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",
|
||||
},
|
||||
tooltip: {
|
||||
borderRadius: metrics.radius,
|
||||
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)",
|
||||
},
|
||||
/** The positioning shell: `TooltipWithBounds` owns its inline transform, so nothing visual may sit here. */
|
||||
tooltipShell: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: layers.tooltip,
|
||||
},
|
||||
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 shapes 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, shared.tabularNums)}
|
||||
>
|
||||
{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: TimeScale;
|
||||
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) => formatCount(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. `bleed` extends the segment down into the
|
||||
* one drawn before it, so antialiasing cannot open a seam of ground between
|
||||
* two touching fills: the blank lines the owner saw across the client chart.
|
||||
*/
|
||||
export function StackSegment({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
fill,
|
||||
bleed = 0,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
bleed?: number;
|
||||
}) {
|
||||
if (height <= 0) return null;
|
||||
return <rect x={x} y={y} width={width} height={height + bleed} fill={fill} />;
|
||||
}
|
||||
|
||||
/** The amount one upper segment overlaps the one under it, in plot units. */
|
||||
export const STACK_BLEED = 0.5;
|
||||
|
||||
/**
|
||||
* The transparent hit targets: one per bucket, spanning the whole plot height so
|
||||
* that pointing at the empty space above a short column or a low point still
|
||||
* selects it.
|
||||
*
|
||||
* The bands tile the plot edge to edge with their boundaries midway between
|
||||
* neighbouring marks, so no pixel belongs to no bucket and landing in a band is
|
||||
* already "snap to the nearest bucket". The same tiling serves band centres
|
||||
* (bars) and points spread edge to edge (the area chart).
|
||||
*/
|
||||
export function HitBands({
|
||||
plot,
|
||||
centers,
|
||||
onEnter,
|
||||
}: {
|
||||
plot: Plot;
|
||||
/** The x of each bucket's mark, in bucket order. */
|
||||
centers: number[];
|
||||
onEnter: (index: number) => void;
|
||||
}) {
|
||||
const right = plot.x + plot.width;
|
||||
return (
|
||||
<>
|
||||
{centers.map((cx, index) => {
|
||||
const x0 = index === 0 ? plot.x : (centers[index - 1] + cx) / 2;
|
||||
const x1 = index === centers.length - 1 ? right : (cx + centers[index + 1]) / 2;
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
x={x0}
|
||||
y={plot.y}
|
||||
width={Math.max(0, x1 - x0)}
|
||||
height={plot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => onEnter(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The x a bar'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 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. What it keeps is the
|
||||
* positioning; the box the reader sees is the element inside it.
|
||||
*/
|
||||
const NO_INLINE_STYLE = {};
|
||||
|
||||
/** The gap a tooltip hung from a chart's corner keeps from its anchor point. */
|
||||
const CORNER_OFFSET = 8;
|
||||
|
||||
/**
|
||||
* The gap a tooltip beside a point keeps from it: enough that the box never
|
||||
* covers the dot or the slot of data the reader is looking at.
|
||||
*/
|
||||
const BESIDE_OFFSET = 16;
|
||||
|
||||
export function ChartTooltip({
|
||||
content,
|
||||
index,
|
||||
left,
|
||||
top = 0,
|
||||
beside = false,
|
||||
}: {
|
||||
content: TooltipContent;
|
||||
/** Which item the tooltip names; part of what forces a fresh measurement. */
|
||||
index: number;
|
||||
left: number;
|
||||
top?: number;
|
||||
/**
|
||||
* Beside the anchor at its own height rather than hung from the chart's top:
|
||||
* the area chart's placement, where `top` is the point's y. The centring is a
|
||||
* negative half-height `offsetTop`, not a CSS transform, so that the flip
|
||||
* `TooltipWithBounds` computes from its own rect sees where the box really
|
||||
* lands. It flips the box to the anchor's left when it would run past the
|
||||
* right edge, and above the anchor when it would run past the bottom.
|
||||
*/
|
||||
beside?: boolean;
|
||||
}) {
|
||||
// `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("|");
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
// The centring offset is half the box's own height, so it cannot be known
|
||||
// before the box exists. `measureKey` remounts the whole tooltip per content
|
||||
// change, so one measurement per mount covers every size the box takes.
|
||||
useLayoutEffect(() => {
|
||||
if (box.current) setHeight(box.current.offsetHeight);
|
||||
}, []);
|
||||
return (
|
||||
<TooltipWithBounds
|
||||
key={measureKey}
|
||||
left={left}
|
||||
top={top}
|
||||
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
|
||||
offsetTop={beside ? -height / 2 : CORNER_OFFSET}
|
||||
style={NO_INLINE_STYLE}
|
||||
className={stylex.props(styles.tooltipShell).className}
|
||||
>
|
||||
<div ref={box} {...stylex.props(styles.tooltip)}>
|
||||
<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>
|
||||
</div>
|
||||
</TooltipWithBounds>
|
||||
);
|
||||
}
|
||||
|
||||
const legendStyles = stylex.create({
|
||||
legend: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
columnGap: "1rem",
|
||||
rowGap: "0.25rem",
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
item: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
/** The key under a chart: one swatch and one name per drawn series, in drawing order. */
|
||||
export function ChartLegend({ entries }: { entries: { key: string; label: string; color: string }[] }) {
|
||||
return (
|
||||
<ul {...stylex.props(legendStyles.legend)}>
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.key} {...stylex.props(legendStyles.item)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(legendStyles.swatch, legendStyles.swatchColor(entry.color))}
|
||||
/>
|
||||
{entry.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { parseClient } from "./clientScope";
|
||||
|
||||
test("one address passes, and nothing else does", () => {
|
||||
expect(parseClient("192.0.2.30")).toBe("192.0.2.30");
|
||||
expect(parseClient("2001:db8::1")).toBe("2001:db8::1");
|
||||
expect(parseClient("::1")).toBe("::1");
|
||||
expect(parseClient("::ffff:192.0.2.30")).toBe("192.0.2.30");
|
||||
expect(parseClient("fe80::1%25eth0")).toBeUndefined();
|
||||
// An IPv4 tail is the last 32 bits, so nothing may follow it.
|
||||
expect(parseClient("192.0.2.30::")).toBeUndefined();
|
||||
expect(parseClient("::192.0.2.30:1")).toBeUndefined();
|
||||
expect(parseClient("2001:db8:0:0:0:0:0:0:1")).toBeUndefined();
|
||||
expect(parseClient("2001:db8::1::2")).toBeUndefined();
|
||||
expect(parseClient("2001:db8::1:2:3:4:5:6:7")).toBeUndefined();
|
||||
expect(parseClient("256.0.0.1")).toBeUndefined();
|
||||
expect(parseClient("192.0.2")).toBeUndefined();
|
||||
expect(parseClient(" 192.0.2.30")).toBeUndefined();
|
||||
expect(parseClient("abc")).toBeUndefined();
|
||||
expect(parseClient(undefined)).toBeUndefined();
|
||||
expect(parseClient("")).toBeUndefined();
|
||||
expect(parseClient(7)).toBeUndefined();
|
||||
// A list is the Activity filter's grammar, not this page's.
|
||||
expect(parseClient("192.0.2.30,192.0.2.31")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an IPv6 address is reduced to the spelling the logger stores", () => {
|
||||
expect(parseClient("2001:DB8::1")).toBe("2001:db8::1");
|
||||
expect(parseClient("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe("2001:db8::1");
|
||||
expect(parseClient("2001:db8:0:0:1:0:0:1")).toBe("2001:db8::1:0:0:1");
|
||||
expect(parseClient("1:0:0:1:0:0:0:1")).toBe("1:0:0:1::1");
|
||||
expect(parseClient("0:0:0:0:0:0:0:0")).toBe("::");
|
||||
expect(parseClient("2001:db8:0:1:1:1:1:1")).toBe("2001:db8:0:1:1:1:1:1");
|
||||
});
|
||||
|
||||
/**
|
||||
* `src/platform/address.zig` prints hex groups with RFC 5952 compression and
|
||||
* nothing else, and it normalizes an IPv4-mapped address to the plain IPv4, so
|
||||
* a dotted tail in a pasted link has to be folded the same way to match.
|
||||
*/
|
||||
test("an IPv4 tail is folded into hextets, and a mapped address into its IPv4", () => {
|
||||
expect(parseClient("2001:db8::192.0.2.30")).toBe("2001:db8::c000:21e");
|
||||
expect(parseClient("::ffff:192.0.2.30")).toBe("192.0.2.30");
|
||||
expect(parseClient("::FFFF:C000:021E")).toBe("192.0.2.30");
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The device Overview is scoped to, as URL state beside the period.
|
||||
*
|
||||
* The API takes one exact address (no list) and matches the text the logger
|
||||
* stored, so that is what the route lets through: a value that is not an IPv4
|
||||
* or IPv6 address is dropped from the URL rather than sent on to a 400, and an
|
||||
* IPv6 address is reduced to the RFC 5952 spelling the logger writes, so an
|
||||
* uppercase or expanded form in a pasted link still finds its client. The
|
||||
* server writes hex groups only, so an IPv4 tail is folded into the two hextets
|
||||
* it names and an IPv4-mapped address is reduced to its plain IPv4. An absent
|
||||
* scope is the whole household.
|
||||
*/
|
||||
|
||||
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
||||
const HEXTET = /^[0-9a-f]{1,4}$/i;
|
||||
|
||||
/** The two hextets an IPv4 tail names: `192.0.2.30` is `c000:21e`. */
|
||||
function ipv4Hextets(dotted: string): string[] {
|
||||
const octets = dotted.split(".").map(Number);
|
||||
return [((octets[0] << 8) | octets[1]).toString(16), ((octets[2] << 8) | octets[3]).toString(16)];
|
||||
}
|
||||
|
||||
/**
|
||||
* One side of a `::` as hextets, or null. An IPv4 tail is folded into the two
|
||||
* hextets it names — the server prints an IPv6 address as hex groups only,
|
||||
* never with a dotted tail — and it is read only where the address ends, since
|
||||
* the tail is the last 32 bits and nothing may follow it.
|
||||
*/
|
||||
function sideHextets(parts: string[], dottedAllowed: boolean): string[] | null {
|
||||
const last = parts[parts.length - 1];
|
||||
const hextets =
|
||||
last !== undefined && last.includes(".")
|
||||
? dottedAllowed && IPV4.test(last)
|
||||
? [...parts.slice(0, -1), ...ipv4Hextets(last)]
|
||||
: null
|
||||
: parts;
|
||||
if (hextets === null) return null;
|
||||
return hextets.every((group) => HEXTET.test(group)) ? hextets : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 4291 text — up to eight hextets, one `::` at most, an IPv4 tail allowed
|
||||
* as the last 32 bits — parsed to its eight hextets, or null.
|
||||
*/
|
||||
function ipv6Groups(value: string): string[] | null {
|
||||
const halves = value.split("::");
|
||||
if (halves.length > 2) return null;
|
||||
const split = halves.length === 2;
|
||||
const head = sideHextets(halves[0] === "" ? [] : (halves[0] as string).split(":"), !split);
|
||||
const tail = sideHextets(split && halves[1] !== "" ? (halves[1] as string).split(":") : [], split);
|
||||
if (head === null || tail === null) return null;
|
||||
const width = head.length + tail.length;
|
||||
if (split ? width >= 8 : width !== 8) return null;
|
||||
const zeros = Array.from({ length: 8 - width }, () => "0");
|
||||
const expanded = split ? [...head, ...zeros, ...tail] : head;
|
||||
return expanded.map((group) => group.replace(/^0+(?=.)/, "").toLowerCase());
|
||||
}
|
||||
|
||||
/** RFC 5952: lowercase, no leading zeros, the longest run of two or more zero groups as `::` (the first on a tie). */
|
||||
function compressIpv6(groups: string[]): string {
|
||||
let best = { start: -1, length: 0 };
|
||||
for (let i = 0; i < groups.length;) {
|
||||
if (groups[i] !== "0") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < groups.length && groups[j] === "0") j += 1;
|
||||
if (j - i >= 2 && j - i > best.length) best = { start: i, length: j - i };
|
||||
i = j;
|
||||
}
|
||||
if (best.start < 0) return groups.join(":");
|
||||
const head = groups.slice(0, best.start).join(":");
|
||||
const tail = groups.slice(best.start + best.length).join(":");
|
||||
return `${head}::${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dotted IPv4 an IPv4-mapped address stands for, or null. The server
|
||||
* normalizes `::ffff:a.b.c.d` to the plain `a.b.c.d`, so a link that spells the
|
||||
* mapped form has to be reduced the same way to find its client.
|
||||
*/
|
||||
function mappedIpv4(groups: string[]): string | null {
|
||||
if (groups.slice(0, 5).some((group) => group !== "0") || groups[5] !== "ffff") return null;
|
||||
const high = Number.parseInt(groups[6] as string, 16);
|
||||
const low = Number.parseInt(groups[7] as string, 16);
|
||||
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
||||
}
|
||||
|
||||
export function parseClient(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (IPV4.test(value)) return value;
|
||||
const groups = ipv6Groups(value);
|
||||
if (groups === null) return undefined;
|
||||
return mappedIpv4(groups) ?? compressIpv6(groups);
|
||||
}
|
||||
@@ -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 the body of the scope the reader just left, period or
|
||||
* device, must never render under the new scope'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,217 +43,96 @@ 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());
|
||||
|
||||
function Probe({ period }: { period: Period }) {
|
||||
const overview = useOverviewWindow(period);
|
||||
return (
|
||||
<ul>
|
||||
{OVERVIEW_ENDPOINTS.map((endpoint) => {
|
||||
const panel = overview[endpoint];
|
||||
/** The last retry the hook handed out, so a test can spend it. */
|
||||
let lastRetry: () => void;
|
||||
|
||||
function Probe({ period, client }: { period: Period; client?: string }) {
|
||||
const panel = useOverviewWindow(period, client);
|
||||
if (panel.status === "error") lastRetry = panel.retry;
|
||||
const detail =
|
||||
panel.status === "ready"
|
||||
? `${panel.data.period}@${panel.data.until}/${panel.data.coverage.available_since}`
|
||||
? `${panel.data.period}@${panel.data.until}`
|
||||
: panel.status === "error"
|
||||
? (panel.error as Error).message
|
||||
: "";
|
||||
return <li key={endpoint}>{`${endpoint}:${panel.status}:${detail}`}</li>;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
return <p>{`${panel.status}:${detail}`}</p>;
|
||||
}
|
||||
|
||||
function renderProbe(period: Period = "24h") {
|
||||
function renderProbe(period: Period = "24h", scope?: string) {
|
||||
const client = createQueryClient();
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe period={period} />
|
||||
<Probe period={period} client={scope} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return {
|
||||
rerenderWith: (next: Period) =>
|
||||
rerenderWith: (next: Period, nextScope?: string) =>
|
||||
view.rerender(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe period={next} />
|
||||
<Probe period={next} client={nextScope} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function line(endpoint: OverviewEndpoint): string {
|
||||
const item = screen.getAllByRole("listitem").find((element) => element.textContent?.startsWith(`${endpoint}:`));
|
||||
if (item === undefined) throw new Error(`no probe line for ${endpoint}`);
|
||||
return item.textContent ?? "";
|
||||
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;
|
||||
|
||||
failing = false;
|
||||
lastRetry();
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
expect(calls).toBeGreaterThan(spent);
|
||||
});
|
||||
|
||||
test("a laggard that stays behind fails its own panel and leaves the rest rendering", async () => {
|
||||
bounds.types = { until: UNTIL - 3600, availableSince: SINCE };
|
||||
renderProbe();
|
||||
|
||||
await waitFor(() => expect(line("types")).toContain("error"));
|
||||
expect(line("types")).toContain("different window");
|
||||
// One retry, not a loop.
|
||||
expect(calls.types).toBe(2);
|
||||
for (const endpoint of ["totals", "timeseries", "clients", "routes"] as const) {
|
||||
expect(line(endpoint)).toContain("ready");
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed request degrades its own panel; the charts keep the window", async () => {
|
||||
failing.add("routes");
|
||||
renderProbe();
|
||||
|
||||
await waitFor(() => expect(line("routes")).toContain("error"));
|
||||
expect(line("routes")).toContain("endpoint unavailable");
|
||||
expect(line("timeseries")).toContain("ready");
|
||||
expect(line("totals")).toContain("ready");
|
||||
});
|
||||
|
||||
test("a watermark that advanced mid-page is a mismatch, not a mixed window", async () => {
|
||||
// Same bounds, later watermark: retention pruned between the two responses.
|
||||
bounds.clients = { until: UNTIL, availableSince: SINCE + 600 };
|
||||
renderProbe();
|
||||
|
||||
await waitFor(() => expect(line("clients")).toContain(`/${SINCE + 600}`));
|
||||
// The page adopts the later watermark, so the four older answers are the
|
||||
// laggards and each gets its one retry rather than rendering beside it.
|
||||
await waitFor(() => expect(calls.totals).toBe(2));
|
||||
expect(line("clients")).toContain("ready");
|
||||
});
|
||||
|
||||
test("a retained previous-period body never renders under the new period's label", async () => {
|
||||
test("a previous period's 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@");
|
||||
// The 24h body is a complete answer and still the wrong one to draw under
|
||||
// "1h", so the page waits for its own.
|
||||
expect(line()).toBe("loading:");
|
||||
await waitFor(() => expect(line()).toBe(`ready:1h@${UNTIL}`));
|
||||
});
|
||||
|
||||
test("a period change buys the new window its own retry", async () => {
|
||||
bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
|
||||
test("rescoping to a device under the same period waits for that device's body", async () => {
|
||||
const { rerenderWith } = renderProbe("24h");
|
||||
await waitFor(() => expect(line("routes")).toContain("error"));
|
||||
const spent = calls.routes;
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
|
||||
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"));
|
||||
rerenderWith("24h", "192.0.2.30");
|
||||
// Same period, so the household body would pass a period check; it is
|
||||
// still the wrong scope to draw under the device's name.
|
||||
expect(line()).toBe("loading:");
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${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.
|
||||
* The query key carries the period and the client, so the body the hook
|
||||
* returns is always the body of the scope the toolbar names. A rescope shows
|
||||
* the loading state until its own answer lands rather than the previous
|
||||
* scope's charts under the new label: a complete, self-consistent body for the
|
||||
* wrong device is still the wrong body.
|
||||
*/
|
||||
|
||||
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 { useQuery } from "@tanstack/react-query";
|
||||
import { overviewQuery } from "@/lib/queries";
|
||||
import type { Overview, Period } from "@/lib/types";
|
||||
|
||||
export type Panel<T> =
|
||||
{ status: "loading" } | { status: "error"; error: unknown; retry: () => void } | { status: "ready"; data: T };
|
||||
|
||||
export interface OverviewWindow {
|
||||
/** Null until one response for the selected period has arrived. */
|
||||
window: WindowId | null;
|
||||
/** The adopted window's watermark, for the page's single coverage notice. */
|
||||
coverage: Coverage | null;
|
||||
totals: Panel<StatsTotals>;
|
||||
timeseries: Panel<StatsTimeseries>;
|
||||
clients: Panel<StatsClients>;
|
||||
types: Panel<StatsTypes>;
|
||||
routes: Panel<StatsRoutes>;
|
||||
}
|
||||
export function useOverviewWindow(period: Period, client: string | undefined): Panel<Overview> {
|
||||
const query = useQuery(overviewQuery(period, client));
|
||||
const { refetch } = query;
|
||||
const retry = useCallback(() => void refetch(), [refetch]);
|
||||
|
||||
/**
|
||||
* A laggard that stayed behind after its one retry. Not an `ApiError`: nothing
|
||||
* failed, the endpoint simply never caught up, and `InlineError` renders the
|
||||
* message verbatim.
|
||||
*/
|
||||
export const MISMATCH = new Error("This panel is for a different window than the rest of the page. Try again.");
|
||||
|
||||
export function useOverviewWindow(period: Period): OverviewWindow {
|
||||
const queries: { [K in OverviewEndpoint]: UseQueryResult<EndpointBodies[K]> } = {
|
||||
totals: useQuery({ ...statsQuery(period), placeholderData: keepPreviousData }),
|
||||
timeseries: useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData }),
|
||||
clients: useQuery({ ...statsClientsQuery(period), placeholderData: keepPreviousData }),
|
||||
types: useQuery({ ...statsTypesQuery(period), placeholderData: keepPreviousData }),
|
||||
routes: useQuery({ ...statsRoutesQuery(period), placeholderData: keepPreviousData }),
|
||||
};
|
||||
|
||||
// A `keepPreviousData` placeholder for the period just left is a complete,
|
||||
// self-consistent body — and still the wrong one to show under the new label,
|
||||
// so it is neither a candidate for the window nor a member of it.
|
||||
const answers = new Map<OverviewEndpoint, WindowId>();
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
const data = queries[endpoint].data;
|
||||
if (data !== undefined && data.period === period) answers.set(endpoint, windowIdOf(data));
|
||||
}
|
||||
|
||||
let window: WindowId | null = null;
|
||||
for (const id of answers.values()) window = window === null ? id : newerWindow(window, id);
|
||||
|
||||
// The effect below runs on what the responses say, not on how many times they
|
||||
// arrived: a poll that returns byte-identical data must not restart the retry
|
||||
// bookkeeping. The refetchers ride a ref for the same reason — TanStack hands
|
||||
// back a fresh function identity on some renders, and depending on it would
|
||||
// re-enter the effect with nothing changed.
|
||||
const answersKey = OVERVIEW_ENDPOINTS.map((endpoint) => {
|
||||
const id = answers.get(endpoint);
|
||||
return id === undefined ? "" : keyOf(id);
|
||||
}).join("~");
|
||||
const latest = useRef({ answers, refetch: queries });
|
||||
latest.current = { answers, refetch: queries };
|
||||
|
||||
// Which mismatch episode each endpoint has already spent its retry on, keyed
|
||||
// by endpoint and window identity so a new window buys a new attempt.
|
||||
const retriedFor = useRef(new Map<OverviewEndpoint, string>());
|
||||
// Which retry each endpoint is waiting on. Per endpoint, because one shared
|
||||
// counter would let a second endpoint's retry silence the first's completion;
|
||||
// bumped on every retry issued, so a completion from a window or a period the
|
||||
// page has left can neither clear an error the current one reached nor spend
|
||||
// the current window's one retry.
|
||||
const tokens = useRef(new Map<OverviewEndpoint, number>());
|
||||
// State, not a ref: a retry that returns byte-identical data changes nothing
|
||||
// else a render could see, and the panel still has to reach its error.
|
||||
const [landedFor, setLandedFor] = useState(new Map<OverviewEndpoint, string>());
|
||||
|
||||
// Leaving a period ends every episode it opened. A retry issued for the old
|
||||
// period can still be in flight, and without this its completion would land
|
||||
// under the new one holding a token the map still honours: it would record an
|
||||
// episode as spent, so a return to that window would reach the terminal error
|
||||
// without the retry that error is supposed to follow. Bumping the tokens
|
||||
// orphans those answers, and the cleared maps let the new window start clean.
|
||||
const [lastPeriod, setLastPeriod] = useState(period);
|
||||
if (lastPeriod !== period) {
|
||||
setLastPeriod(period);
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
|
||||
}
|
||||
retriedFor.current.clear();
|
||||
setLandedFor(new Map());
|
||||
}
|
||||
|
||||
const windowKey = window === null ? null : keyOf(window);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowKey === null) return;
|
||||
for (const [endpoint, identity] of latest.current.answers) {
|
||||
if (keyOf(identity) === windowKey) {
|
||||
retriedFor.current.delete(endpoint);
|
||||
continue;
|
||||
}
|
||||
const episode = `${endpoint}|${windowKey}`;
|
||||
if (retriedFor.current.get(endpoint) === episode) continue;
|
||||
retriedFor.current.set(endpoint, episode);
|
||||
const token = (tokens.current.get(endpoint) ?? 0) + 1;
|
||||
tokens.current.set(endpoint, token);
|
||||
const landed = () => {
|
||||
if (tokens.current.get(endpoint) !== token) return;
|
||||
setLandedFor((previous) => new Map(previous).set(endpoint, episode));
|
||||
};
|
||||
void latest.current.refetch[endpoint].refetch().then(landed, landed);
|
||||
}
|
||||
}, [answersKey, windowKey]);
|
||||
|
||||
const retry = useCallback((endpoint: OverviewEndpoint) => {
|
||||
retriedFor.current.delete(endpoint);
|
||||
tokens.current.set(endpoint, (tokens.current.get(endpoint) ?? 0) + 1);
|
||||
setLandedFor((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.delete(endpoint);
|
||||
return next;
|
||||
});
|
||||
void latest.current.refetch[endpoint].refetch();
|
||||
}, []);
|
||||
|
||||
function panelOf<K extends OverviewEndpoint>(endpoint: K): Panel<EndpointBodies[K]> {
|
||||
const query = queries[endpoint];
|
||||
const onRetry = () => retry(endpoint);
|
||||
if (query.isError) return { status: "error", error: query.error, retry: onRetry };
|
||||
const data = query.data;
|
||||
if (
|
||||
data !== undefined &&
|
||||
windowKey !== null &&
|
||||
data.period === period &&
|
||||
keyOf(windowIdOf(data)) === windowKey
|
||||
) {
|
||||
return { status: "ready", data };
|
||||
}
|
||||
if (windowKey !== null && landedFor.get(endpoint) === `${endpoint}|${windowKey}`) {
|
||||
return { status: "error", error: MISMATCH, retry: onRetry };
|
||||
}
|
||||
if (query.isError) return { status: "error", error: query.error, retry };
|
||||
if (query.data !== undefined) return { status: "ready", data: query.data };
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
const panels = {
|
||||
totals: panelOf("totals"),
|
||||
timeseries: panelOf("timeseries"),
|
||||
clients: panelOf("clients"),
|
||||
types: panelOf("types"),
|
||||
routes: panelOf("routes"),
|
||||
};
|
||||
|
||||
// The notice describes the window, so any member of it can supply the
|
||||
// watermark: whichever panel arrived says the same thing about coverage.
|
||||
let coverage: Coverage | null = null;
|
||||
for (const endpoint of OVERVIEW_ENDPOINTS) {
|
||||
const panel = panels[endpoint];
|
||||
if (panel.status === "ready") {
|
||||
coverage = panel.data.coverage;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { window, coverage, ...panels };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
import { OTHER_KEY, clientKey, qtypeKey, routeKey, seriesColor } from "./seriesColors";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import {
|
||||
CHART_GREEN,
|
||||
CHART_RED,
|
||||
OTHER_KEY,
|
||||
clientKey,
|
||||
clientSeriesColor,
|
||||
qtypeKey,
|
||||
routeKey,
|
||||
seriesColor,
|
||||
typeRampColor,
|
||||
} from "./seriesColors";
|
||||
|
||||
test("the four source-less route kinds and other are fixed, so they mean one thing everywhere", () => {
|
||||
expect(seriesColor(routeKey("blocked", null))).toBe("#ef4444");
|
||||
expect(seriesColor(routeKey("cache", null))).toBe("#059669");
|
||||
expect(seriesColor(routeKey("local", null))).toBe("#8b5cf6");
|
||||
expect(seriesColor(routeKey("rejected", null))).toBe("#f59e0b");
|
||||
expect(seriesColor(OTHER_KEY)).toBe("#71717a");
|
||||
expect(seriesColor(routeKey("blocked", null))).toBe(CHART_RED);
|
||||
expect(seriesColor(routeKey("cache", null))).toBe(CHART_GREEN);
|
||||
expect(seriesColor(routeKey("local", null))).toBe(colors.seriesViolet);
|
||||
expect(seriesColor(routeKey("rejected", null))).toBe(colors.seriesAmber);
|
||||
expect(seriesColor(OTHER_KEY)).toBe(colors.seriesOther);
|
||||
});
|
||||
|
||||
test("every colour is a token reference, so the charts and the CSS cannot disagree", () => {
|
||||
for (const value of [CHART_RED, CHART_GREEN, seriesColor(OTHER_KEY), clientSeriesColor(0), typeRampColor(0)]) {
|
||||
expect(value).toMatch(/^var\(--/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the client chart colours by rank: eight distinct hues, then round again", () => {
|
||||
const first = Array.from({ length: 8 }, (_, rank) => clientSeriesColor(rank));
|
||||
expect(new Set(first).size).toBe(8);
|
||||
expect(clientSeriesColor(8)).toBe(clientSeriesColor(0));
|
||||
// Never the aggregate's gray, and never the reserved red.
|
||||
expect(first).not.toContain(seriesColor(OTHER_KEY));
|
||||
expect(first).not.toContain(CHART_RED);
|
||||
});
|
||||
|
||||
test("the types ring steps one hue outward and a long tail shares the lightest step", () => {
|
||||
const steps = Array.from({ length: 6 }, (_, rank) => typeRampColor(rank));
|
||||
expect(new Set(steps).size).toBe(6);
|
||||
expect(typeRampColor(6)).toBe(typeRampColor(5));
|
||||
expect(typeRampColor(40)).toBe(typeRampColor(5));
|
||||
});
|
||||
|
||||
test("the colour of a key depends on the key and on nothing else", () => {
|
||||
@@ -52,7 +85,7 @@ test("a panel of realistic entries gets a spread of hues, not one colour repeate
|
||||
test("a dynamic entry never takes a fixed entry's colour", () => {
|
||||
// The bug this rules out: a nameless upstream row coming out the same red as
|
||||
// the Blocked slice beside it in the same ring.
|
||||
const fixedColors = new Set(["#ef4444", "#059669", "#8b5cf6", "#f59e0b", "#71717a"]);
|
||||
const fixedColors = new Set([CHART_RED, CHART_GREEN, seriesColor(OTHER_KEY)]);
|
||||
const keys = [routeKey("upstream", null), routeKey("forward_zone", "lan"), qtypeKey(28), qtypeKey(null)];
|
||||
for (const key of keys) expect(fixedColors.has(seriesColor(key))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,39 +1,68 @@
|
||||
/**
|
||||
* A colour per thing, not per position.
|
||||
* Which colour a series or slice wears, as a StyleX var from `tokens.stylex.ts`.
|
||||
* An SVG `fill` or `stroke` attribute takes a var reference as readily as CSS
|
||||
* does (`stroke={colors.surfaceRaised}` renders `var(--…)`), so no chart holds
|
||||
* a literal of its own and the palette lives in one place.
|
||||
*
|
||||
* Every series and slice on Overview is ranked by count, and a rank that changes
|
||||
* between two thirty-second polls would recolour the whole panel if colour came
|
||||
* from the ordinal. So colour keys on the entry's semantic identity: the qtype
|
||||
* value, the client string, or — for routes — the full `(route, source)` pair,
|
||||
* because keying on the route kind alone would paint two adjacent upstream
|
||||
* slices the same and merge them into one shape.
|
||||
*
|
||||
* The four source-less route kinds and the "other" bucket are fixed rather than
|
||||
* hashed: they mean the same thing on every install, and Blocked and Cache
|
||||
* already have colours on the query-volume timeline.
|
||||
* The routes ring keys colour on identity rather than on rank: a rank that
|
||||
* changes between two thirty-second polls would recolour the whole panel if
|
||||
* colour came from the ordinal, and the fixed kinds (blocked, cache, local,
|
||||
* rejected) mean the same thing on every install. The client chart and the
|
||||
* types ring rank instead — see their functions.
|
||||
*/
|
||||
|
||||
import type { RouteKind } from "@/lib/types";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
export const CHART_BLUE = colors.primary;
|
||||
export const CHART_RED = colors.chartRed;
|
||||
export const CHART_GREEN = colors.chartGreen;
|
||||
|
||||
/**
|
||||
* The dynamic hues, validated for CVD separation and 3:1 contrast against both
|
||||
* surfaces; the same hex in light and dark, as the timeline's series are. The
|
||||
* five fixed colours below are deliberately not in here: a nameless upstream row
|
||||
* must not come out the same red as Blocked in the ring beside it.
|
||||
* The categorical palette in rank order. The fixed colours below are
|
||||
* deliberately not in here: a nameless upstream row must not come out the same
|
||||
* red as Blocked in the ring beside it.
|
||||
*/
|
||||
const PALETTE = ["#3b82f6", "#ec4899", "#14b8a6", "#f97316", "#6366f1", "#84cc16", "#06b6d4", "#a855f7"] as const;
|
||||
const PALETTE = [
|
||||
colors.seriesBlue,
|
||||
colors.seriesTeal,
|
||||
colors.seriesViolet,
|
||||
colors.seriesAmber,
|
||||
colors.seriesGreen,
|
||||
colors.seriesMagenta,
|
||||
colors.seriesOrange,
|
||||
colors.seriesOlive,
|
||||
] as const;
|
||||
|
||||
const FIXED: Record<string, string> = {
|
||||
"route:blocked": "#ef4444",
|
||||
"route:cache": "#059669",
|
||||
"route:local": "#8b5cf6",
|
||||
"route:rejected": "#f59e0b",
|
||||
other: "#71717a",
|
||||
"route:blocked": CHART_RED,
|
||||
"route:cache": CHART_GREEN,
|
||||
"route:local": colors.seriesViolet,
|
||||
"route:rejected": colors.seriesAmber,
|
||||
other: colors.seriesOther,
|
||||
};
|
||||
|
||||
/** The identity of everything outside the top eight clients. */
|
||||
export const OTHER_KEY = "other";
|
||||
|
||||
/**
|
||||
* The client chart's series colours go by rank, not by identity: the API ranks
|
||||
* the eight busiest clients and eight hues hashed over eight identities collide
|
||||
* almost surely, which is exactly the merged-band failure the owner rejected.
|
||||
* A client that changes rank between polls changes colour; a legend beside the
|
||||
* chart names every band, so the trade is legibility for stability.
|
||||
*/
|
||||
export function clientSeriesColor(rank: number): string {
|
||||
return PALETTE[rank % PALETTE.length];
|
||||
}
|
||||
|
||||
const TYPE_RAMP = [colors.ramp1, colors.ramp2, colors.ramp3, colors.ramp4, colors.ramp5, colors.ramp6] as const;
|
||||
|
||||
/** The query-types ring: one hue stepped outward from the busiest type; a longer tail shares the lightest. */
|
||||
export function typeRampColor(rank: number): string {
|
||||
return TYPE_RAMP[Math.min(rank, TYPE_RAMP.length - 1)];
|
||||
}
|
||||
|
||||
export function qtypeKey(qtype: number | null): string {
|
||||
return qtype === null ? "qtype:none" : `qtype:${qtype}`;
|
||||
}
|
||||
@@ -62,22 +91,15 @@ function hash(key: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour of one key, and of nothing else.
|
||||
* The colour of one key, and of nothing else: a pure function of the identity,
|
||||
* no panel, no key set, no rank. The routes ring needs that property because
|
||||
* its entries churn between polls, and an assignment that read the whole set
|
||||
* would repaint entries that did not change at all.
|
||||
*
|
||||
* This is a pure function of the identity: no panel, no key set, no rank. That
|
||||
* is the property the page needs, because the panels churn — a client enters the
|
||||
* top eight and another leaves it every few polls — and an assignment that read
|
||||
* the whole set would repaint entries that did not change at all.
|
||||
*
|
||||
* The cost is that a hash is not injective: two entries of one panel can come
|
||||
* out the same hue. That is a real cost and it is the smaller one. Resolving it
|
||||
* by probing would mean the entries that lost a slot depend on which entries
|
||||
* were present, which is the churn this exists to prevent — and eight hues
|
||||
* cannot colour nine things distinctly in any case. The failure a shared hue
|
||||
* would cause instead, two neighbouring slices merging into one shape, is
|
||||
* prevented where it happens: the donut strokes every arc and the client chart
|
||||
* strokes every segment in the surface colour, so equal hues still read as two.
|
||||
* The legend and the hidden table name every entry either way.
|
||||
* A hash is not injective, so two named upstreams can share a hue. The failure
|
||||
* that would cause, two neighbouring slices merging into one shape, is prevented
|
||||
* where it happens: the ring strokes every arc in the surface colour, and the
|
||||
* legend and the hidden table name every entry either way.
|
||||
*/
|
||||
export function seriesColor(key: string): string {
|
||||
return FIXED[key] ?? PALETTE[hash(key) % PALETTE.length];
|
||||
|
||||
@@ -91,11 +91,14 @@ test("unpaused: duration menu pauses with the picked duration_seconds", async ()
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
|
||||
expect(screen.getByRole("button", { name: label })).toBeTruthy();
|
||||
}
|
||||
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
|
||||
"60 seconds",
|
||||
"5 minutes",
|
||||
"30 minutes",
|
||||
"Indefinitely",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
@@ -105,7 +108,7 @@ test("indefinite pause sends no duration_seconds", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Indefinitely" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
@@ -151,11 +154,15 @@ test("an active resolver states nothing: the button already says Pause", async (
|
||||
test("escape closes the duration menu", async () => {
|
||||
renderControl();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
fireEvent.keyDown(trigger, { key: "Escape" });
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
// Opening the menu moves focus into it, so Escape is pressed where the reader
|
||||
// is, not on the trigger they left.
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Pause" }).getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
@@ -168,7 +175,7 @@ test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||
@@ -187,7 +194,7 @@ test("failed pause with 503 shows the degraded message", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "60 seconds" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||
@@ -202,12 +209,12 @@ test("a successful pause clears the previous mutation error", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
await screen.findByRole("alert");
|
||||
|
||||
postFailure = null;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
@@ -240,7 +247,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
protection = { state: "unavailable", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
@@ -253,7 +260,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
|
||||
// reader opened, and nobody opened this one.
|
||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -261,7 +268,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
// Filtering is paused from somewhere else, and this browser learns it from
|
||||
// the poll. The Resume rendering has no menu.
|
||||
@@ -274,7 +281,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
|
||||
|
||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* The Pause/Resume control, in the two places a pause is a valid answer to what
|
||||
* the reader is looking at: the foot of the sidebar, where it belongs to the
|
||||
* resolver rather than to any page, and beside the detail of a query that was
|
||||
* blocked.
|
||||
* The Pause/Resume control, at the foot of the sidebar. A pause stops filtering
|
||||
* for the whole resolver, so it belongs to the shell rather than to any page,
|
||||
* and it has no second placement.
|
||||
*
|
||||
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
|
||||
* the Diagnostics health strip, and it renders nothing at all while protection
|
||||
@@ -20,14 +19,15 @@
|
||||
* line: the button says Pause, which is the whole message.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
||||
import { formatClock } from "@/lib/format";
|
||||
import { pauseMutation } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { useProtection } from "./protection";
|
||||
|
||||
const DURATIONS = [
|
||||
@@ -48,6 +48,8 @@ const styles = stylex.create({
|
||||
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||
},
|
||||
/** The sidebar foot is the only placement, and there Log out sets the width. */
|
||||
width: "100%",
|
||||
},
|
||||
row: {
|
||||
display: "flex",
|
||||
@@ -61,43 +63,44 @@ const styles = stylex.create({
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
anchor: {
|
||||
position: "relative",
|
||||
},
|
||||
menu: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: "100%",
|
||||
zIndex: 10,
|
||||
marginTop: "0.25rem",
|
||||
display: "flex",
|
||||
width: "9rem",
|
||||
flexDirection: "column",
|
||||
borderRadius: "0.25rem",
|
||||
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
|
||||
popover: {
|
||||
width: "var(--trigger-width)",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingBlock: "0.25rem",
|
||||
color: colors.text,
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menu: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
menuItem: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/**
|
||||
* RAC focuses the item's own node, so the shared ring applies; it is inset
|
||||
* because an item flush against the popover edge clips an outset one, and
|
||||
* recoloured because the focus token is the blue this row just painted.
|
||||
*/
|
||||
menuItemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
});
|
||||
|
||||
export default function PauseControl() {
|
||||
const queryClient = useQueryClient();
|
||||
const protection = useProtection();
|
||||
const mutation = useMutation(pauseMutation(queryClient));
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const paused = protection.state === "paused";
|
||||
const { reset } = mutation;
|
||||
useEffect(() => reset(), [paused, reset]);
|
||||
@@ -108,15 +111,6 @@ export default function PauseControl() {
|
||||
// the wrong action, which is the contradiction this control exists to end.
|
||||
const actionable = protection.state === "active" || protection.state === "paused";
|
||||
|
||||
// Leaving `active` unmounts the menu but not the state that opened it, and
|
||||
// the menu belongs to the active rendering alone — a pause someone else
|
||||
// started, seen through the poll, takes it away exactly as a withdrawal does.
|
||||
// Closing on the way out rather than on the way back means the trigger can
|
||||
// only ever come back shut, however long it was gone.
|
||||
useEffect(() => {
|
||||
if (protection.state !== "active") setMenuOpen(false);
|
||||
}, [protection.state]);
|
||||
|
||||
if (!actionable) return null;
|
||||
|
||||
if (paused) {
|
||||
@@ -140,41 +134,40 @@ export default function PauseControl() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...stylex.props(styles.anchor)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="pause-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
isDisabled={mutation.isPending}
|
||||
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
<Menu {...stylex.props(styles.menu)}>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<button
|
||||
<MenuItem
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onAction={() =>
|
||||
mutation.mutate(
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
||||
seconds === null
|
||||
? { paused: true }
|
||||
: { paused: true, duration_seconds: seconds },
|
||||
)
|
||||
}
|
||||
className={({ isFocused }) =>
|
||||
stylex.props(
|
||||
styles.menuItem,
|
||||
shared.insetFocusRing,
|
||||
isFocused && styles.menuItemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { Coverage } from "@/lib/types";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
notice: {
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -17,6 +17,12 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** The same sentence with no box, for a results footer that already has one. */
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -27,11 +33,21 @@ const styles = stylex.create({
|
||||
* watermark and nothing more: the same incompleteness covers a log retention
|
||||
* has pruned and one that simply has not been running long enough, and the
|
||||
* response does not say which.
|
||||
*
|
||||
* `note` is the same sentence without the panel, for a place that already sits
|
||||
* under the data it qualifies — a results footer states the reach of the count
|
||||
* beside it, where a full-width banner would announce a limit as news.
|
||||
*/
|
||||
export default function CoverageNotice({ coverage }: { coverage: Coverage }) {
|
||||
export default function CoverageNotice({
|
||||
coverage,
|
||||
variant = "banner",
|
||||
}: {
|
||||
coverage: Coverage;
|
||||
variant?: "banner" | "note";
|
||||
}) {
|
||||
if (coverage.complete) return null;
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.notice)}>
|
||||
<p role="status" {...stylex.props(variant === "note" ? styles.note : styles.notice)}>
|
||||
Query history is available from {formatTime(coverage.available_since)}.
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
|
||||
const styles = stylex.create({
|
||||
message: {
|
||||
@@ -12,6 +13,7 @@ const styles = stylex.create({
|
||||
color: colors.danger,
|
||||
},
|
||||
retry: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
@@ -44,7 +46,7 @@ export default function InlineError({ error, onRetry }: { error: unknown; onRetr
|
||||
if (error.status === 429) {
|
||||
message =
|
||||
remaining !== null && remaining > 0
|
||||
? `Rate limited. Try again in ${remaining}s.`
|
||||
? `Rate limited. Try again in ${formatDuration(remaining)}.`
|
||||
: "Rate limited. Try again.";
|
||||
} else if (error.status === 503) {
|
||||
message = "The server is starting or degraded. Try again shortly.";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+5
-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,9 @@ 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; `client` scopes it to one device. */
|
||||
export const getOverview = (period?: Period, client?: string): Promise<Overview> =>
|
||||
request(`/api/overview${qs({ period, client })}`);
|
||||
|
||||
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,
|
||||
@@ -74,7 +70,6 @@ export const sample_get_health: Health = {
|
||||
};
|
||||
|
||||
export const sample_get_version: Version = {
|
||||
git_commit: "<build>",
|
||||
uptime_seconds: 0,
|
||||
version: "w10-test",
|
||||
zig_version: "<build>",
|
||||
@@ -588,39 +583,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 +799,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 +872,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,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { formatBytes, formatClock, formatDuration, formatMicros, formatTime } from "@/lib/format";
|
||||
import {
|
||||
formatBytes,
|
||||
formatClock,
|
||||
formatCount,
|
||||
formatDuration,
|
||||
formatMicros,
|
||||
formatPercent,
|
||||
formatRate,
|
||||
formatTime,
|
||||
} from "@/lib/format";
|
||||
|
||||
test("formatTime renders unix seconds in the given locale and zone", () => {
|
||||
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
|
||||
expect(formatTime(1704067200, "en-US", "UTC").replace(/ /g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
|
||||
expect(formatTime(1704067200, "en-US", "UTC").replace(/\u202f/g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
|
||||
});
|
||||
|
||||
test("formatClock states the time of day alone, for a stamp read against now", () => {
|
||||
@@ -12,6 +21,33 @@ test("formatClock states the time of day alone, for a stamp read against now", (
|
||||
expect(formatClock(Date.UTC(2026, 0, 1, 14, 5) / 1000, "en-GB", "UTC")).not.toMatch(/2026/);
|
||||
});
|
||||
|
||||
test("formatCount groups thousands", () => {
|
||||
expect(formatCount(0)).toBe("0");
|
||||
expect(formatCount(999)).toBe("999");
|
||||
expect(formatCount(18432)).toBe("18,432");
|
||||
expect(formatCount(1_234_567)).toBe("1,234,567");
|
||||
});
|
||||
|
||||
test("formatPercent always carries two decimals, rounded half up", () => {
|
||||
expect(formatPercent(0)).toBe("0.00%");
|
||||
expect(formatPercent(1)).toBe("100.00%");
|
||||
expect(formatPercent(0.1544)).toBe("15.44%");
|
||||
expect(formatPercent(0.154)).toBe("15.40%");
|
||||
// A stored 15.435 sits just under the half; it still rounds up.
|
||||
expect(formatPercent(0.15435)).toBe("15.44%");
|
||||
expect(formatPercent(0.15434)).toBe("15.43%");
|
||||
expect(formatPercent(0.005)).toBe("0.50%");
|
||||
expect(formatPercent(0.00005)).toBe("0.01%");
|
||||
expect(formatPercent(0.00004)).toBe("0.00%");
|
||||
});
|
||||
|
||||
test("formatRate carries one decimal, rounded half up", () => {
|
||||
expect(formatRate(12.84)).toBe("12.8");
|
||||
expect(formatRate(12.85)).toBe("12.9");
|
||||
expect(formatRate(5)).toBe("5.0");
|
||||
expect(formatRate(0)).toBe("0.0");
|
||||
});
|
||||
|
||||
test("formatBytes humanizes with binary units", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(1023)).toBe("1023 B");
|
||||
@@ -22,21 +58,25 @@ test("formatBytes humanizes with binary units", () => {
|
||||
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
|
||||
});
|
||||
|
||||
test("formatDuration steps up a unit at each boundary and truncates", () => {
|
||||
expect(formatDuration(0)).toBe("0s");
|
||||
expect(formatDuration(59)).toBe("59s");
|
||||
test("formatDuration is the two largest nonzero units, unpadded", () => {
|
||||
expect(formatDuration(45)).toBe("45s");
|
||||
expect(formatDuration(60)).toBe("1m");
|
||||
expect(formatDuration(3599)).toBe("59m");
|
||||
expect(formatDuration(725)).toBe("12m 5s");
|
||||
expect(formatDuration(3600)).toBe("1h");
|
||||
expect(formatDuration(10800)).toBe("3h");
|
||||
expect(formatDuration(86399)).toBe("23h");
|
||||
expect(formatDuration(86400)).toBe("1d");
|
||||
expect(formatDuration(400000)).toBe("4d");
|
||||
expect(formatDuration(3600 * 4 + 60 * 12 + 30)).toBe("4h 12m");
|
||||
expect(formatDuration(86400 * 6 + 3600 * 4)).toBe("6d 4h");
|
||||
// The two largest that are nonzero, whatever sits between them.
|
||||
expect(formatDuration(86400 * 6 + 5)).toBe("6d 5s");
|
||||
expect(formatDuration(86400 * 6)).toBe("6d");
|
||||
expect(formatDuration(86400 * 400)).toBe("400d");
|
||||
// Never "06d 4h" or "6d 04h".
|
||||
expect(formatDuration(86400 * 6 + 3600 * 4)).not.toMatch(/0\d/);
|
||||
});
|
||||
|
||||
test("formatDuration is never negative", () => {
|
||||
// Clock skew between the server's timestamps and the browser's clock.
|
||||
expect(formatDuration(-5)).toBe("0s");
|
||||
test("formatDuration reads under a second as such, skew included", () => {
|
||||
expect(formatDuration(0)).toBe("<1s");
|
||||
expect(formatDuration(0.9)).toBe("<1s");
|
||||
expect(formatDuration(-5)).toBe("<1s");
|
||||
});
|
||||
|
||||
test("formatMicros renders milliseconds with one decimal", () => {
|
||||
@@ -45,3 +85,27 @@ test("formatMicros renders milliseconds with one decimal", () => {
|
||||
expect(formatMicros(999)).toBe("1.0 ms");
|
||||
expect(formatMicros(2_500_000)).toBe("2500.0 ms");
|
||||
});
|
||||
|
||||
/**
|
||||
* The contract's teeth, as far as a text scan can bite: no source outside this
|
||||
* module calls a number-formatting API. A bare `{count}` in JSX is invisible to
|
||||
* this scan and is caught in review; the scan closes the door on the four ways
|
||||
* a competing rule would be written. `datetime-local` zero-padding in
|
||||
* `activity/datetime.ts` is a wire grammar, not a display rule, and is the one
|
||||
* allowed `padStart`. Identifiers, configured values and preset labels are not
|
||||
* quantities at all and are exempt by the header rule, so the scan never sees
|
||||
* them.
|
||||
*/
|
||||
test("no source file outside format.ts calls a number-formatting API", () => {
|
||||
const sources = import.meta.glob<string>("../**/*.{ts,tsx}", { query: "?raw", import: "default", eager: true });
|
||||
const offenders: string[] = [];
|
||||
for (const [path, text] of Object.entries(sources)) {
|
||||
if (/\.test\.tsx?$/.test(path) || /(^|\/)format\.ts$/.test(path)) continue;
|
||||
for (const pattern of [/\.toFixed\(/, /\.toLocaleString\(/, /Intl\.NumberFormat/]) {
|
||||
if (pattern.test(text)) offenders.push(`${path}: ${pattern.source}`);
|
||||
}
|
||||
if (/\.padStart\(/.test(text) && !path.endsWith("/activity/datetime.ts")) offenders.push(`${path}: padStart`);
|
||||
}
|
||||
expect(Object.keys(sources).length).toBeGreaterThan(50);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
+80
-9
@@ -1,3 +1,23 @@
|
||||
/**
|
||||
* Every number the reader sees, formatted in one place (milestone 39).
|
||||
*
|
||||
* The rules are fixed so that two surfaces never spell one quantity two ways:
|
||||
* counts are grouped, percentages always carry two decimals, rates one, and a
|
||||
* duration is its two largest nonzero units with no zero padding. A call site
|
||||
* that formats inline is a defect; the sweep test in `format.test.ts` and the
|
||||
* review both hunt for one.
|
||||
*
|
||||
* What the contract covers is a measured quantity the reader compares: a count,
|
||||
* a share, a rate, a duration, a size in bytes, a time. Three kinds of number
|
||||
* are not quantities and render as written. An identifier or a protocol code —
|
||||
* a group or source id, an RCODE, a QCLASS, an unknown QTYPE's number — names a
|
||||
* thing rather than measuring one, though a row's ordinal is a count. A
|
||||
* configured value shown next to or inside the input that edits it — a TTL of
|
||||
* 3600, a cache size of 10000, a priority — must read back exactly as the
|
||||
* operator typed it. And the fixed label of a preset in a menu — "Past 24
|
||||
* hours", "5 minutes" — is copy, not a measurement.
|
||||
*/
|
||||
|
||||
/** Unix seconds → localized date-time. `locale`/`timeZone` exist for deterministic tests. */
|
||||
export function formatTime(unixSeconds: number, locale?: string, timeZone?: string): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
@@ -18,6 +38,49 @@ export function formatClock(unixSeconds: number, locale?: string, timeZone?: str
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The label on a chart's time axis: the day for buckets a day or wider, the
|
||||
* time of day otherwise.
|
||||
*/
|
||||
export function formatBucketTime(unixSeconds: number, bucketSeconds: number): string {
|
||||
const date = new Date(unixSeconds * 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 grouped = new Intl.NumberFormat("en-US");
|
||||
|
||||
/** A count, thousands-grouped: 18432 → "18,432". */
|
||||
export function formatCount(count: number): string {
|
||||
return grouped.format(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Round half up at `decimals` places. `toFixed` rounds the binary value, so
|
||||
* 15.435 (stored just under) would print 15.43; the nudge lifts an exact half
|
||||
* over the edge without moving anything else.
|
||||
*/
|
||||
function roundHalfUp(value: number, decimals: number): string {
|
||||
const scale = 10 ** decimals;
|
||||
return (Math.round(value * scale + 1e-9) / scale).toFixed(decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* A share as a percentage, always two decimals: 0.1544 → "15.44%", 0 →
|
||||
* "0.00%", 1 → "100.00%". The caller decides what a share of nothing means; a
|
||||
* total of zero is not this function's to guess.
|
||||
*/
|
||||
export function formatPercent(fraction: number): string {
|
||||
return `${roundHalfUp(fraction * 100, 2)}%`;
|
||||
}
|
||||
|
||||
/** A rate, one decimal: 12.84 → "12.8", 5 → "5.0". */
|
||||
export function formatRate(value: number): string {
|
||||
return roundHalfUp(value, 1);
|
||||
}
|
||||
|
||||
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
@@ -29,28 +92,36 @@ export function formatBytes(bytes: number): string {
|
||||
value /= 1024;
|
||||
if (value < 1024) break;
|
||||
}
|
||||
return `${value.toFixed(1)} ${unit}`;
|
||||
return `${roundHalfUp(value, 1)} ${unit}`;
|
||||
}
|
||||
|
||||
const AGE_UNITS = [
|
||||
const DURATION_UNITS = [
|
||||
{ seconds: 86400, suffix: "d" },
|
||||
{ seconds: 3600, suffix: "h" },
|
||||
{ seconds: 60, suffix: "m" },
|
||||
{ seconds: 1, suffix: "s" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Seconds of elapsed time → a coarse "3h". Truncating and single-unit on
|
||||
* purpose, for a span the caller labels itself, as in "active for 3h". A
|
||||
* negative span reads "0s": clock skew is not a duration.
|
||||
* Seconds of elapsed time → its two largest nonzero units, unpadded: "6d 4h",
|
||||
* "4h 12m", "12m 5s", "6d 5s", "45s". Two units, because "6d" alone hides
|
||||
* four hours and "6d 4h 12m 5s" is a stopwatch; nonzero, because "6d 0h" says
|
||||
* nothing "6d 5s" does not. Anything under a second, a negative span included
|
||||
* — clock skew is not a duration — reads "<1s".
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
for (const unit of AGE_UNITS) {
|
||||
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix}`;
|
||||
let rest = Math.floor(seconds);
|
||||
if (rest < 1) return "<1s";
|
||||
const parts: string[] = [];
|
||||
for (const unit of DURATION_UNITS) {
|
||||
const amount = Math.floor(rest / unit.seconds);
|
||||
rest -= amount * unit.seconds;
|
||||
if (amount > 0) parts.push(`${amount}${unit.suffix}`);
|
||||
}
|
||||
return `${Math.max(0, Math.floor(seconds))}s`;
|
||||
return parts.slice(0, 2).join(" ");
|
||||
}
|
||||
|
||||
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
|
||||
export function formatMicros(micros: number): string {
|
||||
return `${(micros / 1000).toFixed(1)} ms`;
|
||||
return `${formatRate(micros / 1000)} ms`;
|
||||
}
|
||||
|
||||
@@ -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, client: string | undefined) => ["overview", period, client ?? ""] 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,
|
||||
@@ -52,36 +48,12 @@ export const healthQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.health, queryFn: api.getHealth, refetchInterval: 10_000 });
|
||||
|
||||
export const versionQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, staleTime: Infinity });
|
||||
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, refetchInterval: 60_000 });
|
||||
|
||||
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", client?: string) =>
|
||||
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, client),
|
||||
queryFn: () => api.getOverview(period, client),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
|
||||
+20
-37
@@ -63,7 +63,6 @@ export interface Health {
|
||||
|
||||
export interface Version {
|
||||
version: string;
|
||||
git_commit: string;
|
||||
zig_version: string;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
@@ -291,15 +290,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 +306,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;
|
||||
}
|
||||
|
||||
|
||||
+33
-22
@@ -32,18 +32,16 @@ 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 { parseClient } from "@/features/overview/clientScope";
|
||||
import { OverviewPending } from "@/features/overview/OverviewFrame";
|
||||
import {
|
||||
validateGroupId,
|
||||
validateProtectionSearch,
|
||||
@@ -54,6 +52,7 @@ import {
|
||||
import type { Period } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
|
||||
export interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
@@ -61,6 +60,9 @@ export interface RouterContext {
|
||||
|
||||
const styles = stylex.create({
|
||||
pending: {
|
||||
// Holds the page open while the route's chunk lands, so a navigation does
|
||||
// not collapse the main column to one line and scroll the shell.
|
||||
minHeight: "24rem",
|
||||
padding: "2rem",
|
||||
textAlign: "center",
|
||||
color: colors.textMuted,
|
||||
@@ -104,7 +106,10 @@ function RouteError({ error }: ErrorComponentProps) {
|
||||
detail = error.message;
|
||||
} else if (error.status === 429) {
|
||||
title = "Rate limited";
|
||||
detail = error.retryAfter !== undefined ? `Try again in ${error.retryAfter}s.` : "Try again shortly.";
|
||||
detail =
|
||||
error.retryAfter !== undefined
|
||||
? `Try again in ${formatDuration(error.retryAfter)}.`
|
||||
: "Try again shortly.";
|
||||
} else if (error.status >= 500) {
|
||||
title = "Internal error";
|
||||
} else {
|
||||
@@ -156,38 +161,44 @@ const indexRoute = createRoute({
|
||||
});
|
||||
|
||||
/**
|
||||
* Overview. The period is the whole of its applied state, so a view of the page
|
||||
* is a link: a hand-typed or stale value falls back to the default rather than
|
||||
* reaching the API as a parameter it answers 400 to.
|
||||
* Overview. The period and the device scope are the whole of its applied state,
|
||||
* so a view of the page is a link: a hand-typed or stale value falls back to
|
||||
* the default rather than reaching the API as a parameter it answers 400 to.
|
||||
*/
|
||||
const overviewRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/overview",
|
||||
validateSearch: (search: Record<string, unknown>): { period?: Period } => ({
|
||||
validateSearch: (search: Record<string, unknown>): { period?: Period; client?: string } => ({
|
||||
period: parsePeriod(search["period"]),
|
||||
client: parseClient(search["client"]),
|
||||
}),
|
||||
loaderDeps: ({ search }): { period: Period; client: string | undefined } => ({
|
||||
period: search.period ?? DEFAULT_PERIOD,
|
||||
client: search.client,
|
||||
}),
|
||||
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, deps.client)));
|
||||
// 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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import { health } from "@/lib/healthFixture";
|
||||
import type { ConfigStatus, Health } from "@/lib/types";
|
||||
|
||||
const NAV_LABELS = ["Overview", "Activity", "Clients", "Diagnostics"];
|
||||
const CONFIGURATION_LABELS = ["Protection", "Resolution", "System"];
|
||||
const SYSTEM_LABELS = ["Protection", "Resolution", "System", "Diagnostics"];
|
||||
/** The pages the redesign folded into the three configuration ones. */
|
||||
const GONE_LABELS = ["Groups", "Blocklists", "Rules", "Local DNS", "Upstreams", "Settings"];
|
||||
|
||||
@@ -19,49 +19,21 @@ 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 },
|
||||
},
|
||||
"/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } },
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
"/api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
|
||||
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */
|
||||
@@ -138,7 +110,7 @@ test("shell renders the overview route with all nav links", async () => {
|
||||
|
||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||
expect(nav).toBeTruthy();
|
||||
for (const label of [...NAV_LABELS, ...CONFIGURATION_LABELS]) {
|
||||
for (const label of [...NAV_LABELS, ...SYSTEM_LABELS]) {
|
||||
expect(screen.getByRole("link", { name: label })).toBeTruthy();
|
||||
}
|
||||
for (const label of GONE_LABELS) {
|
||||
@@ -155,16 +127,16 @@ test("main carries the ids the router scrolls and restores", async () => {
|
||||
expect(main.getAttribute("data-scroll-restoration-id")).toBe("main");
|
||||
});
|
||||
|
||||
test("the three configuration pages sit under a labelled group, after the rest", async () => {
|
||||
test("the configuration pages and Diagnostics sit under the System group, after Monitoring", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
|
||||
const group = screen.getByRole("list", { name: "Configuration" });
|
||||
const group = screen.getByRole("list", { name: "System" });
|
||||
expect(
|
||||
within(group)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.textContent),
|
||||
).toEqual(CONFIGURATION_LABELS);
|
||||
).toEqual(SYSTEM_LABELS);
|
||||
// The group is a section of Main, not a nav of its own.
|
||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||
expect(nav.contains(group)).toBe(true);
|
||||
@@ -184,14 +156,14 @@ test("under file authority the nav states the file and when it was loaded", asyn
|
||||
|
||||
const line = await screen.findByText(/^File-managed ·/);
|
||||
expect(line.textContent).toBe(`File-managed · ${CONFIG_PATH} · loaded ${formatTime(RECONCILED_AT)}`);
|
||||
const group = screen.getByRole("list", { name: "Configuration" });
|
||||
const group = screen.getByRole("list", { name: "System" });
|
||||
expect(group.parentElement?.contains(line)).toBe(true);
|
||||
});
|
||||
|
||||
test("under database authority there is no authority line to read", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
await screen.findByRole("list", { name: "Configuration" });
|
||||
await screen.findByRole("list", { name: "System" });
|
||||
|
||||
expect(screen.queryByText(/File-managed/)).toBeNull();
|
||||
});
|
||||
@@ -242,22 +214,65 @@ test("mount probe reveals the logout button and a failed logout surfaces inline"
|
||||
|
||||
renderShell();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
||||
// Both renderings are mounted; the viewport paints the sidebar one on WIDE
|
||||
// and the header one below it.
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Log out" })).toHaveLength(2));
|
||||
const aside = document.querySelector("aside") as HTMLElement;
|
||||
fireEvent.click(within(aside).getByRole("button", { name: "Log out" }));
|
||||
|
||||
await screen.findByText("Rate limited. Try again in 7s.");
|
||||
await within(aside).findByText("Rate limited. Try again in 7s.");
|
||||
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the header carries no protection display at all any more", async () => {
|
||||
test("Log out sits in the sidebar on wide, and only in the header below it", async () => {
|
||||
stubFetch((url) =>
|
||||
url === "/api/auth/login"
|
||||
? new Response(JSON.stringify({ error: "password required" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
: null,
|
||||
);
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
|
||||
const aside = document.querySelector("aside") as HTMLElement;
|
||||
const header = document.querySelector("header") as HTMLElement;
|
||||
await waitFor(() => expect(within(aside).getByRole("button", { name: "Log out" })).toBeTruthy());
|
||||
expect(within(header).getByRole("button", { name: "Log out" })).toBeTruthy();
|
||||
// The header is the narrow rendering now: the Menu button is its only nav.
|
||||
expect(within(header).getByRole("button", { name: "Menu" })).toBeTruthy();
|
||||
|
||||
// In the sidebar the control precedes the version label rather than crowding it.
|
||||
const version = within(aside).getByText(/^nxdns v/);
|
||||
const logout = within(aside).getByRole("button", { name: "Log out" });
|
||||
expect(logout.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
// The drawer keeps no third copy: the header already carries the narrow one.
|
||||
fireEvent.click(within(header).getByRole("button", { name: "Menu" }));
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the header carries the protection state as a reading, never as a control", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
// Scoped to the header: "Protection" is a nav destination now, and that is
|
||||
// not the status pill this test buried.
|
||||
const header = within(document.querySelector("header") as HTMLElement);
|
||||
for (const gone of [/^Protection/, /^Paused/]) {
|
||||
expect(header.queryByRole("link", { name: gone })).toBeNull();
|
||||
expect(header.queryByText(gone)).toBeNull();
|
||||
}
|
||||
await waitFor(() => expect(header.getByText("Protection active")).toBeTruthy());
|
||||
expect(header.queryByRole("link", { name: /^Protection/ })).toBeNull();
|
||||
expect(header.queryByRole("button", { name: /^Pause/ })).toBeNull();
|
||||
});
|
||||
|
||||
test("the sidebar's foot states protection and uptime, and queries/min only on Overview", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
const aside = document.querySelector("aside") as HTMLElement;
|
||||
const status = within(aside).getByLabelText("Status");
|
||||
await waitFor(() => expect(within(status).getByText("Active")).toBeTruthy());
|
||||
// 1 second of uptime from the fixture, aged by however long the test took.
|
||||
expect(within(status).getByText("Uptime").nextElementSibling?.textContent).toMatch(/^\d+s$/);
|
||||
// The fixture's overview body: no queries over a 24h window, stated as a rate.
|
||||
await waitFor(() => expect(within(status).getByText("Queries/min")).toBeTruthy());
|
||||
expect(within(status).getByText("Queries/min").nextElementSibling?.textContent).toBe("0.0");
|
||||
});
|
||||
|
||||
test("Pause sits at the foot of the sidebar, above the version label", async () => {
|
||||
@@ -285,6 +300,40 @@ test("the mobile drawer carries the same control, not a header one it lost", asy
|
||||
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the Menu button is a disclosure trigger, and it names the panel it opens", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
const menu = screen.getByRole("button", { name: "Menu" });
|
||||
|
||||
// The trigger states the drawer's state, and points at the drawer itself.
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(menu.getAttribute("aria-controls")).toBe("mobile-nav");
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||
|
||||
fireEvent.click(menu);
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(drawer.getAttribute("hidden")).toBeNull();
|
||||
expect(within(drawer).getByRole("navigation", { name: "Main" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(menu);
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("following a drawer link closes the drawer behind it", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
const menu = screen.getByRole("button", { name: "Menu" });
|
||||
fireEvent.click(menu);
|
||||
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
fireEvent.click(within(drawer).getByRole("link", { name: "Clients" }));
|
||||
|
||||
await waitFor(() => expect(menu.getAttribute("aria-expanded")).toBe("false"));
|
||||
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
|
||||
// The trace a pause leaves on every page. With the header indicator and the
|
||||
// Overview status row both gone, a reader who is not on Diagnostics has only
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user