Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2dd6ad1c7
|
||
|
|
08cdf86ecd
|
||
|
|
6e9a36903e
|
||
|
|
f6fa43a8b4
|
||
|
|
f4562cac26
|
||
|
|
250bdca7e7
|
||
|
|
63f4f96a48
|
||
|
|
52158198cf
|
||
|
|
bf79a22584
|
||
|
|
bcd9bce16c
|
||
|
|
2b790c4c3e
|
||
|
|
1860ff59f5
|
||
|
|
f168247b33
|
||
|
|
d596fd788a
|
||
|
|
f067742adf
|
||
|
|
a4eb749fa7
|
||
|
|
22abcd9b7b
|
||
|
|
3e57f43e08
|
||
|
|
299d7af99c
|
||
|
|
85b8be50a0
|
@@ -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,53 @@ 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.21] - 2026-09-12
|
||||
|
||||
### Changed
|
||||
|
||||
- **An upstream warning means the pool stopped trusting the endpoint.** A Diagnostics episode opens when an upstream reaches the health failure threshold and closes on the success that clears it, instead of one card per lost exchange. Failures during backoff raise the card's occurrence count. The episode detail and the `nxdns check` report name the concrete cause behind the classification, `SendFailed (cause BrokenPipe)`.
|
||||
|
||||
## [0.0.20] - 2026-09-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A blocklist source no longer fails on a stale pooled connection.** The fetcher reused a keep-alive connection from an earlier pass that the server had closed, and the standard client never retries one; every later download from that host failed with `HttpConnectionClosing` at 0 ms. Downloads now send `connection: close` and never enter the pool.
|
||||
|
||||
## [0.0.19] - 2026-09-12
|
||||
|
||||
### Changed
|
||||
|
||||
- **A failed blocklist download says why.** The warning line and the Diagnostics event detail now carry the phase that failed, the concrete cause behind the classification (a reset connection, a truncated chunk, a TLS handshake fault), the HTTP status if a head arrived, the bytes received, and the elapsed time. The separate `http status` warning is folded into that line. Same one line per failed source per pass as before.
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -571,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */
|
||||
@@ -65,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.";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -106,7 +106,7 @@ const styles = stylex.create({
|
||||
segment: {
|
||||
cursor: "pointer",
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.625rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
@@ -139,7 +139,7 @@ const styles = stylex.create({
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
popover: {
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
|
||||
@@ -86,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>) {
|
||||
|
||||
@@ -13,7 +13,7 @@ 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";
|
||||
@@ -40,7 +40,7 @@ const styles = stylex.create({
|
||||
switch: {
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
|
||||
@@ -29,6 +29,7 @@ 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";
|
||||
@@ -79,7 +80,7 @@ const styles = stylex.create({
|
||||
popover: {
|
||||
maxHeight: "16rem",
|
||||
overflowY: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -220,7 +221,7 @@ export function joinClients(ips: readonly string[]): string | undefined {
|
||||
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 `${selected.length} clients`;
|
||||
return `${formatCount(selected.length)} clients`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -373,7 +374,7 @@ export default function ClientFilter({ options, selected, onChange }: Props) {
|
||||
</button>
|
||||
))}
|
||||
{selected.length > MAX_CHIPS && (
|
||||
<span {...stylex.props(styles.chipMore)}>+{selected.length - MAX_CHIPS} more</span>
|
||||
<span {...stylex.props(styles.chipMore)}>+{formatCount(selected.length - MAX_CHIPS)} more</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,12 +12,13 @@ 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";
|
||||
|
||||
@@ -38,7 +39,7 @@ const styles = stylex.create({
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -152,7 +153,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
@@ -184,7 +185,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
{/* The count is the one part of this line that moves as pages load. */}
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{rows.length}</span>{" "}
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{formatCount(rows.length)}</span>{" "}
|
||||
{rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -16,10 +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";
|
||||
@@ -77,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)" },
|
||||
@@ -106,7 +107,7 @@ const styles = stylex.create({
|
||||
},
|
||||
cappedBox: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.dangerBorder,
|
||||
@@ -130,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,
|
||||
@@ -287,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>
|
||||
)}
|
||||
|
||||
@@ -295,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"
|
||||
@@ -346,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>
|
||||
@@ -393,8 +398,8 @@ 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -64,7 +65,7 @@ const styles = stylex.create({
|
||||
},
|
||||
card: {
|
||||
marginTop: "1.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -178,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,
|
||||
|
||||
@@ -21,7 +21,7 @@ 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)";
|
||||
|
||||
@@ -93,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",
|
||||
|
||||
@@ -7,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";
|
||||
@@ -43,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",
|
||||
@@ -73,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",
|
||||
@@ -181,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)}>
|
||||
@@ -197,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)}>
|
||||
|
||||
@@ -185,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)}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||
import type { ClientPrefix, Group } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import {
|
||||
firstProblem,
|
||||
initPrefixEditor,
|
||||
@@ -17,7 +18,7 @@ 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[];
|
||||
@@ -77,7 +78,7 @@ const styles = stylex.create({
|
||||
},
|
||||
removeButton: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -142,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>
|
||||
@@ -210,7 +211,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
<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"
|
||||
@@ -221,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) =>
|
||||
@@ -232,7 +233,7 @@ 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"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatCount } from "@/lib/format";
|
||||
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
|
||||
|
||||
export interface PrefixRow {
|
||||
@@ -65,10 +66,14 @@ export interface PrefixProblem {
|
||||
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "")
|
||||
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
|
||||
return { index, field: "prefix", message: `Row ${formatCount(index + 1)}: prefix is required.` };
|
||||
const priority = row.priority.trim();
|
||||
if (priority !== "" && !/^\d+$/.test(priority))
|
||||
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
|
||||
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 = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { useAuthority, type Authority } from "./authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
@@ -20,7 +20,7 @@ const styles = stylex.create({
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
|
||||
@@ -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,
|
||||
@@ -225,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>
|
||||
);
|
||||
@@ -236,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 },
|
||||
@@ -259,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}>
|
||||
@@ -274,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>
|
||||
@@ -391,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>
|
||||
@@ -428,6 +434,9 @@ 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);
|
||||
@@ -457,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>
|
||||
|
||||
@@ -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,7 @@ 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";
|
||||
@@ -34,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",
|
||||
@@ -131,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>
|
||||
@@ -158,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)}
|
||||
@@ -219,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>
|
||||
@@ -257,12 +268,20 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
onChange={() => toggleEnabled(b)}
|
||||
/>
|
||||
</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)}
|
||||
|
||||
@@ -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,9 +5,10 @@ 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";
|
||||
|
||||
@@ -30,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: {
|
||||
@@ -71,7 +79,7 @@ const styles = stylex.create({
|
||||
gap: "0.25rem",
|
||||
},
|
||||
fieldInput: {
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -105,7 +113,7 @@ const styles = stylex.create({
|
||||
save: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -261,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
|
||||
|
||||
@@ -67,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>
|
||||
@@ -143,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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,11 +4,12 @@ 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";
|
||||
|
||||
@@ -49,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",
|
||||
@@ -75,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",
|
||||
@@ -89,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,
|
||||
@@ -185,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)}>
|
||||
@@ -198,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)}
|
||||
@@ -208,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>
|
||||
|
||||
@@ -77,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(),
|
||||
@@ -153,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();
|
||||
|
||||
@@ -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";
|
||||
@@ -67,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",
|
||||
@@ -116,7 +117,7 @@ const styles = stylex.create({
|
||||
padding: 0,
|
||||
},
|
||||
card: {
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -149,7 +150,7 @@ const styles = stylex.create({
|
||||
},
|
||||
rangeNotice: {
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
@@ -163,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,
|
||||
@@ -235,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[] {
|
||||
@@ -356,7 +357,7 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
|
||||
<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"
|
||||
@@ -494,7 +495,7 @@ export default function DiagnosticsPage() {
|
||||
<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>
|
||||
@@ -522,8 +523,9 @@ export default function DiagnosticsPage() {
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footer, styles.note)}>
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{historyRows.length}</span> 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);
|
||||
|
||||
@@ -26,7 +26,7 @@ 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)";
|
||||
@@ -53,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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import ClientChart, { type ClientChartData } from "./ClientChart";
|
||||
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
|
||||
import { OTHER_KEY, clientSeriesColor, seriesColor } from "./seriesColors";
|
||||
|
||||
const SINCE = 1_700_000_000;
|
||||
const BUCKET = 1800;
|
||||
@@ -89,13 +89,41 @@ test("the value scale covers the tallest column's total, not its largest series"
|
||||
expect(labels[labels.length - 1]).toBe("35");
|
||||
});
|
||||
|
||||
test("the series are drawn in the colour of the client's address, and Other in its own", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [5]));
|
||||
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([seriesColor(clientKey("192.0.2.30")), seriesColor(OTHER_KEY)]);
|
||||
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", () => {
|
||||
@@ -151,7 +179,7 @@ test("a refresh in the same window retells the hovered bucket with the new count
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(
|
||||
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
|
||||
).toEqual(["35", "10", "20", "5"]);
|
||||
).toEqual(["20", "10", "5", "35"]);
|
||||
|
||||
rerender(
|
||||
clients(
|
||||
@@ -165,7 +193,7 @@ test("a refresh in the same window retells the hovered bucket with the new count
|
||||
|
||||
expect(
|
||||
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
|
||||
).toEqual(["39", "11", "22", "6"]);
|
||||
).toEqual(["22", "11", "6", "39"]);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -212,28 +240,52 @@ test("each bucket gets its own tooltip mount, so each is measured for itself", (
|
||||
expect(container.querySelector(".visx-tooltip")).not.toBe(first);
|
||||
});
|
||||
|
||||
test("pointing at a bucket names its total and every series, and dims the rest", () => {
|
||||
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([
|
||||
"Queries",
|
||||
"192.0.2.30",
|
||||
"192.0.2.31",
|
||||
"192.0.2.30",
|
||||
"Other",
|
||||
"All clients",
|
||||
]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["35", "10", "20", "5"]);
|
||||
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(seriesColor(clientKey("192.0.2.30")));
|
||||
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);
|
||||
|
||||
@@ -252,26 +304,25 @@ test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
* appear in the legend, the stack, the tooltip and the table saying only that it
|
||||
* is empty. The named clients stay at zero: a client that went quiet is a fact.
|
||||
*/
|
||||
test("a window where Other counted nothing drops it from every surface", () => {
|
||||
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"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "192.0.2.30"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
|
||||
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "192.0.2.30"]);
|
||||
});
|
||||
|
||||
test("one query outside the named clients is enough to keep Other", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 1]));
|
||||
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
|
||||
"192.0.2.30",
|
||||
"Other",
|
||||
]);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -3,27 +3,28 @@
|
||||
* series per named client, plus everything outside the top eight as "Other".
|
||||
*
|
||||
* The x-axis is derived from this response's own `since` and `bucket_seconds`,
|
||||
* which the API aligns with the timeseries endpoint's buckets, so the two charts
|
||||
* stack directly above one another and a spike in one is at the same horizontal
|
||||
* position in the other. Colour keys on the client string, so a client that
|
||||
* changes rank between polls keeps its colour.
|
||||
* 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 * as stylex from "@stylexjs/stylex";
|
||||
import { Group } from "@visx/group";
|
||||
import { BarStack } from "@visx/shape";
|
||||
import { formatTime } from "@/lib/format";
|
||||
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 { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||
import {
|
||||
BucketOverlay,
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartLegend,
|
||||
ChartRoot,
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
HitBands,
|
||||
STACK_BLEED,
|
||||
StackSegment,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
@@ -35,36 +36,10 @@ import {
|
||||
valueTicks,
|
||||
type TooltipContent,
|
||||
} from "./chartKit";
|
||||
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
|
||||
import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors";
|
||||
|
||||
const styles = stylex.create({
|
||||
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 }),
|
||||
});
|
||||
/** How many named clients a bucket's tooltip lists before the aggregate and the total. */
|
||||
const TOOLTIP_CLIENTS = 4;
|
||||
|
||||
interface Series {
|
||||
key: string;
|
||||
@@ -74,23 +49,20 @@ interface Series {
|
||||
}
|
||||
|
||||
/**
|
||||
* "Other" last, so it sits at the top of every column rather than under a client,
|
||||
* and dropped entirely when it counted nothing across the window: an aggregation
|
||||
* bucket that aggregated nothing is a legend entry, a stack key, a tooltip row
|
||||
* and a table column all saying zero. The named clients stay at zero, because a
|
||||
* client that went quiet is something the reader wants to see.
|
||||
* "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: ClientChartData, names: ClientNames): Series[] {
|
||||
const named = data.clients.map((client) => ({
|
||||
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,
|
||||
color: seriesColor(clientKey(client.client)),
|
||||
color: clientSeriesColor(rank),
|
||||
buckets: client.buckets,
|
||||
}));
|
||||
if (data.other.every((count) => count === 0)) return named;
|
||||
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
|
||||
}
|
||||
|
||||
@@ -129,7 +101,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
|
||||
const timestamps = columns.map((column) => column.ts);
|
||||
const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0));
|
||||
const plot = plotArea(width);
|
||||
const 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
|
||||
@@ -137,19 +109,30 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
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 {
|
||||
return {
|
||||
title: formatTime(columns[index].ts),
|
||||
rows: [
|
||||
{ key: "queries", label: "Queries", value: String(totals[index]) },
|
||||
...series.map((one) => ({
|
||||
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: String(columns[index][one.key]),
|
||||
})),
|
||||
],
|
||||
value: formatCount(column[one.key]),
|
||||
}));
|
||||
return {
|
||||
title: formatTime(column.ts),
|
||||
rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,7 +140,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
<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}`}
|
||||
@@ -187,6 +170,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
>
|
||||
{stacks.map((stack) => {
|
||||
const bar = stack.bars[index];
|
||||
const restsOnAnother = bar.y + bar.height < plot.bottom - STACK_BLEED;
|
||||
return (
|
||||
<StackSegment
|
||||
key={stack.key}
|
||||
@@ -195,6 +179,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
fill={bar.color}
|
||||
bleed={restsOnAnother ? STACK_BLEED : 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -202,23 +187,12 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
|
||||
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(hovered.index)}
|
||||
left={slotCenter(xScale, timestamps[hovered.index], plot)}
|
||||
/>
|
||||
<ChartTooltip index={hovered.index} content={tooltipOf(hovered.index)} left={centers[hovered.index]} />
|
||||
)}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{series.map((one) => (
|
||||
<li key={one.key} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
|
||||
{one.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ChartLegend entries={series} />
|
||||
<div {...stylex.props(shared.srOnly)}>
|
||||
<table>
|
||||
<caption>Queries per client per time bucket</caption>
|
||||
@@ -237,7 +211,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
{series.map((one) => (
|
||||
<td key={one.key}>{column[one.key]}</td>
|
||||
<td key={one.key}>{formatCount(column[one.key])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -57,11 +57,11 @@ test("the ring starts at twelve o'clock and runs clockwise in the order given",
|
||||
const paths = ring(container);
|
||||
// The small slice is drawn first because it was given first.
|
||||
expect(paths[0].getAttribute("fill")).toBe("#112233");
|
||||
expect(paths[0].getAttribute("d")?.startsWith("M0,-90")).toBe(true);
|
||||
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("M90,0")).toBe(true);
|
||||
expect(paths[1].getAttribute("d")?.startsWith("M63.5,0")).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -76,16 +76,16 @@ test("a single entry is a closed ring, not a zero-length arc", () => {
|
||||
expect(paths).toHaveLength(1);
|
||||
const d = paths[0].getAttribute("d") ?? "";
|
||||
expect(d.match(/A/g)).toHaveLength(4);
|
||||
// Two half arcs out at the outer radius and two back at the inner one: a
|
||||
// 180px ring 36px thick.
|
||||
expect(arcRadii(d)).toEqual([90, 90, 54, 54]);
|
||||
// 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.0%")).toHaveLength(2);
|
||||
expect(screen.getAllByText("25.0%")).toHaveLength(2);
|
||||
expect(screen.getAllByText("75.00%")).toHaveLength(2);
|
||||
expect(screen.getAllByText("25.00%")).toHaveLength(2);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -136,9 +136,9 @@ test("pointing at a slice names it and dims the rest", () => {
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(tooltip.previousElementSibling?.textContent).toBe("A");
|
||||
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual(["Queries", "Share"]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
|
||||
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.0%")).toHaveLength(3);
|
||||
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");
|
||||
@@ -146,11 +146,11 @@ test("pointing at a slice names it and dims the rest", () => {
|
||||
// The tooltip points at the middle of the arc, which the component computes
|
||||
// from the slice values rather than from the drawn path. Slice A is three
|
||||
// quarters of the ring, so its midpoint is at 135 degrees, on a circle of
|
||||
// radius 72 — (140.9, 140.9) from the ring's top-left corner, plus the 8px
|
||||
// the tooltip stands off by. Nothing else here would catch that arithmetic
|
||||
// drifting away from the ring the Pie actually draws.
|
||||
// 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(149px, 149px)");
|
||||
expect(tooltipBox.style.transform).toBe("translate(124px, 124px)");
|
||||
});
|
||||
|
||||
test("leaving the ring takes the tooltip and the dimming with it", () => {
|
||||
@@ -175,14 +175,14 @@ test("a refresh keeps the hovered slice current and remeasures it", () => {
|
||||
|
||||
fireEvent.mouseOver(ring(container)[0]);
|
||||
const first = container.querySelector(".visx-tooltip");
|
||||
expect(Array.from(first?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
|
||||
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.0%",
|
||||
"75.00%",
|
||||
]);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
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 { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { ChartTooltip, useActiveIndex, type TooltipContent } from "./chartKit";
|
||||
|
||||
export interface DonutSlice {
|
||||
@@ -29,38 +30,25 @@ export interface DonutSlice {
|
||||
color: string;
|
||||
}
|
||||
|
||||
const SIZE = 180;
|
||||
const THICKNESS = 36;
|
||||
const OUTER_RADIUS = SIZE / 2;
|
||||
/** 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 gap `body` puts between the ring and the legend, in pixels: 1.25rem. */
|
||||
const BODY_GAP = 20;
|
||||
/** One `legend` row, in pixels: its 1.25rem line height. */
|
||||
const LEGEND_ROW = 20;
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
/** The width at which the page puts the two donuts side by side, and the page's
|
||||
* own grid switches on the same query. StyleX will not take it from an import,
|
||||
* so it is written out in both modules and must be changed in both. */
|
||||
const TWO_COLUMN = "@media (min-width: 1280px)";
|
||||
/** 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 a legend, not the ring alone: 180 + 20 + 20 =
|
||||
* 220px. `body` wraps once the panel is narrower than the ring plus the
|
||||
* legend's 12rem floor, and below that width the filled panel is the ring, the
|
||||
* body gap and at least one legend row. Side by side the same 220px holds a
|
||||
* legend of nine rows, which is more than either breakdown draws — the API
|
||||
* caps neither, so the ring's own height is not a ceiling.
|
||||
*/
|
||||
/** 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 + BODY_GAP + LEGEND_ROW,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -68,18 +56,11 @@ 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. */
|
||||
@@ -88,28 +69,36 @@ const styles = stylex.create({
|
||||
flexShrink: 0,
|
||||
lineHeight: 0,
|
||||
},
|
||||
/**
|
||||
* Capped and left-anchored. Without the cap the row justifies across whatever
|
||||
* the panel is given — most of a metre of whitespace on a wide monitor — and a
|
||||
* label stops reading as belonging to the count opposite it.
|
||||
*/
|
||||
centerTotal: {
|
||||
fill: colors.text,
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.01em",
|
||||
},
|
||||
centerUnit: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "0.75rem",
|
||||
},
|
||||
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",
|
||||
@@ -133,20 +122,17 @@ 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
|
||||
@@ -157,8 +143,8 @@ function sliceAnchor(drawn: DonutSlice[], index: number, total: number): { left:
|
||||
const middle = (2 * Math.PI * (before + drawn[index].value / 2)) / total;
|
||||
const radius = (OUTER_RADIUS + INNER_RADIUS) / 2;
|
||||
return {
|
||||
left: OUTER_RADIUS + Math.sin(middle) * radius,
|
||||
top: OUTER_RADIUS - Math.cos(middle) * radius,
|
||||
left: SIZE / 2 + Math.sin(middle) * radius,
|
||||
top: SIZE / 2 - Math.cos(middle) * radius,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,7 +156,7 @@ 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;
|
||||
}) {
|
||||
// Zero-valued entries have no arc to draw and a legend entry reading 0 is
|
||||
@@ -186,8 +172,8 @@ export default function Donut({
|
||||
return {
|
||||
title: slice.secondary === undefined ? slice.label : `${slice.label} (${slice.secondary})`,
|
||||
rows: [
|
||||
{ key: "value", label: unit, color: slice.color, value: numberFormat.format(slice.value) },
|
||||
{ key: "share", label: "Share", value: sharePercent(slice.value / total) },
|
||||
{ key: "value", label: unit, color: slice.color, value: formatCount(slice.value) },
|
||||
{ key: "share", label: "Share", value: formatPercent(slice.value / total) },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -199,8 +185,8 @@ export default function Donut({
|
||||
return (
|
||||
<div {...stylex.props(styles.body)}>
|
||||
<div {...stylex.props(styles.ring)}>
|
||||
{/* The ring stays out of the accessibility tree even though it is now
|
||||
a pointer target: the tooltip repeats what the legend beside it
|
||||
{/* 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"
|
||||
@@ -213,7 +199,7 @@ export default function Donut({
|
||||
{/* Arc paths are generated around the origin, and `Pie`'s own
|
||||
`top`/`left` group is skipped when it is given a render prop, so
|
||||
the ring is centred here instead. */}
|
||||
<Group top={OUTER_RADIUS} left={OUTER_RADIUS}>
|
||||
<Group top={SIZE / 2} left={SIZE / 2}>
|
||||
<Pie
|
||||
data={drawn}
|
||||
pieValue={(slice) => slice.value}
|
||||
@@ -230,13 +216,10 @@ export default function Donut({
|
||||
>
|
||||
{({ arcs, path }) =>
|
||||
arcs.map((arc, index) => (
|
||||
// The stroke is what keeps a shared hue from lying. Colour is a
|
||||
// pure function of identity, so two neighbouring slices can come
|
||||
// out the same; outlined in the panel's own colour they still
|
||||
// read as two shapes rather than merging into one. Attributes
|
||||
// rather than a class, as the client chart's segments are, so
|
||||
// the separation is visible to a test and not only to a
|
||||
// stylesheet.
|
||||
// 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.data.key}
|
||||
d={path(arc) ?? ""}
|
||||
@@ -249,6 +232,12 @@ export default function Donut({
|
||||
))
|
||||
}
|
||||
</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>
|
||||
{hovered.index !== null && (
|
||||
@@ -269,11 +258,10 @@ export default function Donut({
|
||||
<span {...stylex.props(styles.secondary)}>{slice.secondary}</span>
|
||||
)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
||||
{numberFormat.format(slice.value)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.share, shared.tabularNums)}>
|
||||
{sharePercent(slice.value / total)}
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
@@ -296,8 +284,8 @@ export default function Donut({
|
||||
? slice.label
|
||||
: `${slice.label} (${slice.secondary})`}
|
||||
</th>
|
||||
<td>{slice.value}</td>
|
||||
<td>{sharePercent(slice.value / total)}</td>
|
||||
<td>{formatCount(slice.value)}</td>
|
||||
<td>{formatPercent(slice.value / total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The part of Overview that does not wait for anything: the heading, the period
|
||||
* picker, and the pulsing body the page shows while the window is in flight.
|
||||
* 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
|
||||
@@ -8,19 +9,37 @@
|
||||
* 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 { Radio, RadioGroup } from "react-aria-components";
|
||||
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, metrics } from "@/ui/tokens.stylex";
|
||||
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: "1rem",
|
||||
gap: "1.25rem",
|
||||
},
|
||||
headingRow: {
|
||||
display: "flex",
|
||||
@@ -30,60 +49,47 @@ const styles = stylex.create({
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
margin: 0,
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.015em",
|
||||
textWrap: "balance",
|
||||
},
|
||||
periodGroup: {
|
||||
/** Device on the left, period on the right; on a phone the pair takes the whole row. */
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
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%" },
|
||||
},
|
||||
/**
|
||||
* The weight lives here rather than on the selected variant: selection may
|
||||
* change colour, but a heavier label would re-measure the row and shift every
|
||||
* option beside it.
|
||||
*/
|
||||
period: {
|
||||
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",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.625rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
||||
periodFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
||||
periodSelected: {
|
||||
backgroundColor: {
|
||||
default: "oklch(92% 0.004 286.32)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||
},
|
||||
color: colors.text,
|
||||
},
|
||||
periodIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: colors.textSecondary,
|
||||
transitionProperty: metrics.transitionProperty,
|
||||
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
|
||||
},
|
||||
/**
|
||||
* The height approximates the filled overview — stat tiles, a 240px chart and
|
||||
* a 180px donut with the panel chrome around them — so that the page does not
|
||||
* jump when the window lands. That is where the number comes from.
|
||||
* 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",
|
||||
@@ -93,31 +99,72 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
||||
return (
|
||||
<RadioGroup
|
||||
aria-label="Period"
|
||||
orientation="horizontal"
|
||||
value={period}
|
||||
onChange={(next) => onChange(next as Period)}
|
||||
className={() => stylex.props(styles.periodGroup).className ?? ""}
|
||||
>
|
||||
{PERIODS.map((option) => (
|
||||
<Radio
|
||||
key={option}
|
||||
value={option}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.period,
|
||||
isSelected ? styles.periodSelected : styles.periodIdle,
|
||||
isFocusVisible && styles.periodFocusVisible,
|
||||
).className ?? ""
|
||||
export interface OverviewScope {
|
||||
period: Period;
|
||||
client: string | undefined;
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,19 +177,19 @@ export function OverviewLoading() {
|
||||
}
|
||||
|
||||
export function OverviewFrame({
|
||||
period,
|
||||
scope,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
period: Period;
|
||||
onChange: (period: Period) => void;
|
||||
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>
|
||||
<PeriodPicker period={period} onChange={onChange} />
|
||||
<OverviewToolbar scope={scope} onChange={onChange} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
@@ -150,17 +197,33 @@ export function OverviewFrame({
|
||||
}
|
||||
|
||||
/**
|
||||
* The route's pending surface. The picker stays live because it only writes the
|
||||
* search parameter, which the route already re-reads on its own.
|
||||
* 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 period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
|
||||
const navigate = useNavigate({ from: "/overview" });
|
||||
const [scope, setScope] = useOverviewScope();
|
||||
return (
|
||||
<OverviewFrame
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
>
|
||||
<OverviewFrame scope={scope} onChange={setScope}>
|
||||
<OverviewLoading />
|
||||
</OverviewFrame>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { clientKey, qtypeKey, seriesColor } from "./seriesColors";
|
||||
import { clientSeriesColor, typeRampColor } from "./seriesColors";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Health, Overview } from "@/lib/types";
|
||||
|
||||
@@ -113,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 } });
|
||||
}
|
||||
@@ -144,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 });
|
||||
@@ -155,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"));
|
||||
@@ -167,16 +187,20 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
|
||||
}
|
||||
});
|
||||
|
||||
test("the page builds a donut slice's colour from the entry's identity", async () => {
|
||||
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 identity to hue is the page's job and is pinned here.
|
||||
// mapping from rank to ramp step is the page's job and is pinned here.
|
||||
renderApp();
|
||||
await screen.findByText("1,000");
|
||||
await screen.findAllByText("1,000");
|
||||
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
|
||||
|
||||
const item = within(panel("Query types")).getAllByText("A")[0].closest("li") as HTMLElement;
|
||||
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
|
||||
expect(swatch.getAttribute("style")).toContain(seriesColor(qtypeKey(1)));
|
||||
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 () => {
|
||||
@@ -189,7 +213,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
|
||||
renderApp();
|
||||
|
||||
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
||||
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
|
||||
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");
|
||||
@@ -198,7 +222,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
|
||||
|
||||
release();
|
||||
delayed = null;
|
||||
await screen.findByText("1,000");
|
||||
await screen.findAllByText("1,000");
|
||||
expect(screen.queryByText("Loading…")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -234,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"));
|
||||
@@ -244,13 +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 drops Other in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. An aggregation bucket that
|
||||
// aggregated nothing is a legend entry and a table column that say only that
|
||||
// they are empty; the named clients stay, because a quiet client is a fact.
|
||||
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" });
|
||||
|
||||
@@ -259,17 +283,27 @@ test("the client chart drops Other in a period where it counted nothing", async
|
||||
// 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("192.0.2.30")).toHaveLength(2));
|
||||
expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0);
|
||||
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();
|
||||
@@ -280,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(
|
||||
@@ -306,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();
|
||||
@@ -326,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");
|
||||
@@ -336,47 +391,87 @@ 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");
|
||||
// One radio group named Period, holding the four periods and exactly one
|
||||
// selection: the segmented picker is a single choice, not four toggles.
|
||||
const picker = within(screen.getByRole("radiogroup", { name: "Period" }));
|
||||
expect(picker.getAllByRole("radio").map((radio) => radio.getAttribute("value"))).toEqual([
|
||||
"1h",
|
||||
"24h",
|
||||
"7d",
|
||||
"30d",
|
||||
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",
|
||||
]);
|
||||
expect(picker.getByRole("radio", { name: "1h", checked: true })).toBeTruthy();
|
||||
expect(picker.getByRole("radio", { name: "24h", checked: false })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
|
||||
const router = renderApp("/overview?period=90d");
|
||||
await screen.findByText("1,000");
|
||||
expect(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
|
||||
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("radio", { 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("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");
|
||||
|
||||
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();
|
||||
@@ -388,18 +483,18 @@ test("a failed request is one error for the whole page, stated once and retryabl
|
||||
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
|
||||
// The heading and the picker survive it, so the reader can rescope or retry.
|
||||
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
|
||||
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
|
||||
expect(periodTrigger()).toBeTruthy();
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
|
||||
failing = false;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await screen.findByText("1,000");
|
||||
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,12 +1,13 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
@@ -14,29 +15,20 @@
|
||||
*/
|
||||
|
||||
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 { Overview, OverviewRouteRow, OverviewTypeRow } from "@/lib/types";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
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 "./Donut";
|
||||
import { OverviewFrame, OverviewLoading } from "./OverviewFrame";
|
||||
import { OverviewFrame, OverviewLoading, useOverviewScope } from "./OverviewFrame";
|
||||
import { useOverviewWindow, type Panel } from "./overviewWindow";
|
||||
import { DEFAULT_PERIOD } 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,31 +40,17 @@ const ROUTE_LABELS = {
|
||||
} as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
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))" },
|
||||
gap: "1.25rem",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(20rem, 1fr))",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The page's three states. The heading and the period picker stay put through
|
||||
* all three, so the reader can rescope or retry without waiting for anything.
|
||||
* 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 PageBody({ panel, children }: { panel: Panel<Overview>; children: (data: Overview) => React.ReactNode }) {
|
||||
if (panel.status === "error") return <InlineError error={panel.error} onRetry={panel.retry} />;
|
||||
@@ -80,12 +58,13 @@ function PageBody({ panel, children }: { panel: Panel<Overview>; children: (data
|
||||
return <>{children(panel.data)}</>;
|
||||
}
|
||||
|
||||
/** 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) => ({
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -108,56 +87,67 @@ function routeSlices(routes: OverviewRouteRow[]): 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 (
|
||||
<OverviewFrame
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
>
|
||||
<OverviewFrame scope={scope} onChange={setScope}>
|
||||
<PageBody panel={overview}>
|
||||
{(data) => (
|
||||
<>
|
||||
<StatTiles stats={{ since: data.since, until: data.until, ...data.totals }} />
|
||||
<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} />
|
||||
|
||||
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
|
||||
Queries over time
|
||||
</h2>
|
||||
<Card
|
||||
title="Queries over time"
|
||||
description="Every query the resolver answered in this period, with the blocked share along the bottom."
|
||||
>
|
||||
<TimeseriesChart data={data} />
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
|
||||
Client activity over time
|
||||
</h2>
|
||||
<Card
|
||||
title="Client activity over time"
|
||||
description="Which devices made the queries, stacked per bucket."
|
||||
>
|
||||
<ClientChart data={data} />
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<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" />
|
||||
</section>
|
||||
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
|
||||
Upstream servers
|
||||
</h2>
|
||||
</Card>
|
||||
<Card
|
||||
title="Upstream servers"
|
||||
description="How each query was answered: by which resolver, from cache, or not at all."
|
||||
>
|
||||
<Donut
|
||||
slices={routeSlices(data.routes)}
|
||||
caption="Queries by how they were answered"
|
||||
unit="Queries"
|
||||
/>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,110 +1,128 @@
|
||||
/**
|
||||
* 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 **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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** The window's totals with the bounds they were measured over. */
|
||||
/** 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 }) {
|
||||
@@ -113,14 +131,11 @@ export default function StatTiles({ stats }: { stats: StatTilesData }) {
|
||||
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 }}
|
||||
@@ -128,13 +143,8 @@ export default function StatTiles({ stats }: { stats: StatTilesData }) {
|
||||
>
|
||||
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 }}
|
||||
@@ -142,21 +152,18 @@ export default function StatTiles({ stats }: { stats: StatTilesData }) {
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,17 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { Bucket } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { CHART_BLUE, CHART_RED } from "./seriesColors";
|
||||
import TimeseriesChart, { type TimeseriesData } from "./TimeseriesChart";
|
||||
|
||||
const SINCE = 1_700_000_000;
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
@@ -34,6 +41,18 @@ 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={counting(3)} />);
|
||||
|
||||
@@ -57,13 +76,13 @@ test("the hidden data table is clipped by a block wrapper, not by the table itse
|
||||
});
|
||||
|
||||
/**
|
||||
* "Allowed" is what the reported total leaves over, and the three counts come
|
||||
* "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 draw a segment upside down.
|
||||
* 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: 8, cached: 5 }])} />,
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 12, cached: 5 }])} />,
|
||||
);
|
||||
|
||||
const row = within(screen.getByRole("table")).getAllByRole("row")[1];
|
||||
@@ -71,13 +90,9 @@ test("allowed is the remainder of the reported total, clamped at zero", () => {
|
||||
within(row)
|
||||
.getAllByRole("cell")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["10", "8", "5", "0"]);
|
||||
// Blocked and cached are drawn; the empty "allowed" segment is not.
|
||||
expect(container.querySelectorAll('rect[fill="#3b82f6"]')).toHaveLength(0);
|
||||
).toEqual(["10", "12", "0"]);
|
||||
|
||||
// The scale comes from the reported total, not from the stack's own sum.
|
||||
// Scaling to the sum would reach 13 here and leave the bar four fifths of the
|
||||
// way up a plot whose own numbers say it is full.
|
||||
// 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");
|
||||
});
|
||||
@@ -102,18 +117,80 @@ test("no bucket at all says the same thing", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The three category colours are fixed constants of this chart rather than
|
||||
* anything derived from `seriesColors`, which would paint "other" grey.
|
||||
* 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("the segments are drawn in this chart's own category colours", () => {
|
||||
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 fills = Array.from(container.querySelectorAll("rect"))
|
||||
.map((rect) => rect.getAttribute("fill"))
|
||||
.filter((fill) => fill !== "transparent");
|
||||
expect(fills).toEqual(["#ef4444", "#059669", "#3b82f6"]);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -126,7 +203,7 @@ test("hover text is the tooltip alone, never a bare SVG title", () => {
|
||||
expect(container.querySelectorAll("title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
/** The hit target is the whole column slot, including the space above a short stack. */
|
||||
/** 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)} />);
|
||||
|
||||
@@ -139,20 +216,30 @@ test("each bucket's hit target spans the full plot height", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* A band scale spends a gap after the last column as well as between them, so a
|
||||
* full-step hit target on the last bucket would reach into the right margin and
|
||||
* catch pointers that are past the plot entirely.
|
||||
* 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 last hit target stops at the plot's right edge", () => {
|
||||
test("the hit targets tile the plot edge to edge, split midway between points", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
const last = overlayRects(container).at(-1) as SVGRectElement;
|
||||
const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
|
||||
// 640 fallback width, less the 44px left and 8px right margins.
|
||||
expect(right).toBeCloseTo(44 + 588, 6);
|
||||
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);
|
||||
});
|
||||
|
||||
test("pointing at a bucket names its total and every series, and dims the rest", () => {
|
||||
/**
|
||||
* 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([
|
||||
@@ -161,39 +248,62 @@ test("pointing at a bucket names its total and every series, and dims the rest",
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
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 values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
|
||||
expect(values).toEqual(["100", "40", "10", "50"]);
|
||||
const terms = Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent);
|
||||
expect(terms).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
|
||||
// One swatch per series, in the colour the segment is drawn in.
|
||||
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[0]).toContain("#ef4444");
|
||||
expect(swatches[1]).toContain("#059669");
|
||||
expect(swatches[2]).toContain("#3b82f6");
|
||||
|
||||
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
|
||||
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
|
||||
expect(swatches).toHaveLength(2);
|
||||
expect(swatches[0]).toContain(CHART_BLUE);
|
||||
expect(swatches[1]).toContain(CHART_RED);
|
||||
});
|
||||
|
||||
/**
|
||||
* The tooltip sits 8px down from the chart's top edge and 8px to the side of the
|
||||
* slot it names — the placement the hand-rolled tooltip had, restored over
|
||||
* visx's own 10px defaults.
|
||||
* 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 is offset 8px from the chart top and from the bucket it names", () => {
|
||||
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]);
|
||||
|
||||
// The first slot spans 44 to 44 + step, so its centre is 191.5 and the
|
||||
// tooltip sits 8px right of it. visx rounds the placement to whole pixels.
|
||||
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
expect(tooltip.style.transform).toBe("translate(200px, 8px)");
|
||||
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)`);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -232,7 +342,7 @@ test("a refresh in the same window retells the hovered bucket with the new count
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(
|
||||
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
|
||||
).toEqual(["100", "40", "10", "50"]);
|
||||
).toEqual(["100", "40", "60"]);
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
@@ -246,17 +356,17 @@ test("a refresh in the same window retells the hovered bucket with the new count
|
||||
const values = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map(
|
||||
(dd) => dd.textContent,
|
||||
);
|
||||
expect(values).toEqual(["120", "60", "10", "50"]);
|
||||
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 goes away
|
||||
* rather than describing a bucket that is no longer drawn, and nothing stays
|
||||
* dimmed behind it. Rolling back to the earlier window must not bring it back
|
||||
* either: the selection is deleted when the window moves, not held aside.
|
||||
* 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 down instead of relabelling it", () => {
|
||||
test("a refresh that rolls the window takes the tooltip and the marks down", () => {
|
||||
const { container, rerender } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
@@ -268,6 +378,7 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
expect(container.querySelector("g[data-hover]")).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
@@ -282,8 +393,7 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
|
||||
);
|
||||
|
||||
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);
|
||||
expect(container.querySelector("g[data-hover]")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
@@ -299,9 +409,9 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
|
||||
|
||||
/**
|
||||
* The same bucket can change width between refreshes — a count crossing a digit
|
||||
* boundary, a client name arriving — and a mount measured at the old width is
|
||||
* placed at the wrong one. A content change therefore remounts the tooltip, the
|
||||
* same way moving between buckets does.
|
||||
* 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(
|
||||
@@ -319,7 +429,7 @@ test("a bucket whose numbers change is remounted, so it is measured again", () =
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
test("leaving the chart takes the tooltip and the marks with it", () => {
|
||||
const { container } = render(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
@@ -327,61 +437,36 @@ test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
|
||||
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);
|
||||
expect(container.querySelector("g[data-hover]")).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The third series is every query neither blocked nor served from cache. It was
|
||||
* called "Other", which named the arithmetic rather than the thing.
|
||||
* 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 remainder series is called Allowed everywhere it surfaces", () => {
|
||||
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(["Blocked", "Cached", "Allowed"]);
|
||||
expect(legend).toEqual(["Total queries", "Blocked"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "Queries", "Blocked", "Cached", "Allowed"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
|
||||
expect(terms.map((term) => term.textContent)).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
|
||||
).toEqual(["Time", "Queries", "Blocked", "Allowed"]);
|
||||
expect(screen.queryByText("Other")).toBeNull();
|
||||
expect(screen.queryByText("Cached")).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* A window where everything was blocked or served from cache has no allowed
|
||||
* queries, and that is worth reading rather than hiding: an absent series would
|
||||
* say the same thing as a series nobody looked at. The three categories are all
|
||||
* real answers a query can get, so none of them is dropped for counting zero.
|
||||
* The client chart's "Other" is dropped at zero, but that one aggregates clients
|
||||
* beyond the top eight rather than naming a kind of answer.
|
||||
*/
|
||||
test("a window with nothing allowed keeps the series at zero", () => {
|
||||
const { container } = render(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 4, blocked: 4, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 6, blocked: 6, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
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 legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
|
||||
expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
const tooltip = container.querySelector("dl") as HTMLElement;
|
||||
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual([
|
||||
"Queries",
|
||||
"Blocked",
|
||||
"Cached",
|
||||
"Allowed",
|
||||
]);
|
||||
expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["4", "4", "0", "0"]);
|
||||
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,81 +1,69 @@
|
||||
/**
|
||||
* 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 { Group } from "@visx/group";
|
||||
import { BarStack } from "@visx/shape";
|
||||
import { formatTime } from "@/lib/format";
|
||||
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 {
|
||||
BucketOverlay,
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartLegend,
|
||||
ChartRoot,
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
StackSegment,
|
||||
bandScale,
|
||||
HitBands,
|
||||
areaPath,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
slotCenter,
|
||||
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).
|
||||
// The third key stays `other` — it is the colour key and the response field, and
|
||||
// renaming it would repaint the series. Only what the reader sees is "Allowed".
|
||||
/**
|
||||
* 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: "Allowed", 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;
|
||||
|
||||
type Series = (typeof SERIES)[number];
|
||||
type SeriesKey = Series["key"];
|
||||
type SeriesKey = (typeof SERIES)[number]["key"];
|
||||
|
||||
const SERIES_COLOR: Record<SeriesKey, string> = {
|
||||
blocked: SERIES[0].color,
|
||||
cached: SERIES[1].color,
|
||||
other: SERIES[2].color,
|
||||
};
|
||||
const LINE_WIDTH = 2;
|
||||
const DOT_RADIUS = 4;
|
||||
|
||||
const styles = stylex.create({
|
||||
legend: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
columnGap: "1rem",
|
||||
rowGap: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
guide: {
|
||||
stroke: colors.borderStrong,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
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,
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
interface Column {
|
||||
ts: number;
|
||||
queries: number;
|
||||
blocked: number;
|
||||
cached: number;
|
||||
/** queries - blocked - cached, clamped at 0. */
|
||||
other: number;
|
||||
/** queries - blocked, clamped at 0. */
|
||||
allowed: number;
|
||||
}
|
||||
|
||||
function columnsOf(buckets: Bucket[]): Column[] {
|
||||
@@ -83,8 +71,7 @@ function columnsOf(buckets: Bucket[]): Column[] {
|
||||
ts: bucket.ts,
|
||||
queries: bucket.queries,
|
||||
blocked: bucket.blocked,
|
||||
cached: bucket.cached,
|
||||
other: Math.max(0, bucket.queries - bucket.blocked - bucket.cached),
|
||||
allowed: Math.max(0, bucket.queries - bucket.blocked),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -92,13 +79,13 @@ function tooltipOf(column: Column): TooltipContent {
|
||||
return {
|
||||
title: formatTime(column.ts),
|
||||
rows: [
|
||||
{ key: "queries", label: "Queries", value: String(column.queries) },
|
||||
...SERIES.map((series) => ({
|
||||
key: series.key,
|
||||
label: series.label,
|
||||
color: series.color,
|
||||
value: String(column[series.key]),
|
||||
value: formatCount(column[series.key]),
|
||||
})),
|
||||
{ key: "allowed", label: "Allowed", value: formatCount(column.allowed) },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -125,18 +112,20 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
|
||||
const columns = columnsOf(data.buckets);
|
||||
const timestamps = columns.map((column) => column.ts);
|
||||
const plot = plotArea(width);
|
||||
const xScale = bandScale(timestamps, plot);
|
||||
// The scale is the reported total rather than the stack's own sum: blocked
|
||||
// and cached are parts of `queries`, which a clamped `other` can undercount.
|
||||
const yScale = valueScale(Math.max(...columns.map((column) => column.queries)), [plot.bottom, plot.y]);
|
||||
const 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 (
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Queries over time, ${data.buckets.length} buckets: ${SERIES.map((series) => series.label.toLowerCase()).join(", ")} 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}`}
|
||||
@@ -150,54 +139,61 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
<BarStack<Column, SeriesKey>
|
||||
data={columns}
|
||||
keys={SERIES.map((series) => series.key)}
|
||||
x={(column) => column.ts}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
color={(key) => SERIES_COLOR[key]}
|
||||
>
|
||||
{(stacks) =>
|
||||
columns.map((column, index) => (
|
||||
<Group
|
||||
key={column.ts}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
>
|
||||
{stacks.map((stack) => {
|
||||
const bar = stack.bars[index];
|
||||
{SERIES.map((series) => {
|
||||
const points = pointsOf(series.key);
|
||||
const line = smoothPath(points, plot.y, plot.bottom);
|
||||
return (
|
||||
<StackSegment
|
||||
key={stack.key}
|
||||
x={bar.x}
|
||||
y={bar.y}
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
fill={bar.color}
|
||||
<g key={series.key} data-series={series.key}>
|
||||
<path
|
||||
d={areaPath(line, points, plot)}
|
||||
fill={series.color}
|
||||
fillOpacity={series.fillOpacity}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={series.color}
|
||||
strokeWidth={LINE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
|
||||
{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) => (
|
||||
<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)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(columns[hovered.index])}
|
||||
left={slotCenter(xScale, timestamps[hovered.index], plot)}
|
||||
left={centers[hovered.index]}
|
||||
top={yScale(columns[hovered.index].queries)}
|
||||
beside
|
||||
/>
|
||||
)}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{SERIES.map((series) => (
|
||||
<li key={series.key} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(series.color))} />
|
||||
{series.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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>
|
||||
@@ -205,21 +201,17 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Queries</th>
|
||||
{SERIES.map((series) => (
|
||||
<th key={series.key} scope="col">
|
||||
{series.label}
|
||||
</th>
|
||||
))}
|
||||
<th scope="col">Blocked</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((column) => (
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
<td>{column.queries}</td>
|
||||
{SERIES.map((series) => (
|
||||
<td key={series.key}>{column[series.key]}</td>
|
||||
))}
|
||||
<td>{formatCount(column.queries)}</td>
|
||||
<td>{formatCount(column.blocked)}</td>
|
||||
<td>{formatCount(column.allowed)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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,
|
||||
@@ -33,6 +38,91 @@ describe("valueScale", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The plumbing the two bar charts on Overview share: the width measurement, the
|
||||
* scales, the axis and grid chrome, the segment separator, the per-bucket hit
|
||||
* target and the tooltip.
|
||||
* 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
|
||||
@@ -13,14 +13,20 @@ 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 } from "@visx/scale";
|
||||
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 } from "@/ui/tokens.stylex";
|
||||
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;
|
||||
|
||||
@@ -41,12 +47,20 @@ export interface Plot {
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function plotArea(width: number): Plot {
|
||||
/**
|
||||
* 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: MARGIN.left,
|
||||
x: left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||
width: Math.max(0, width - left - MARGIN.right),
|
||||
height,
|
||||
bottom: MARGIN.top + height,
|
||||
};
|
||||
@@ -86,9 +100,13 @@ export function valueScale(max: number, range: [number, number]) {
|
||||
|
||||
export type ValueScale = ReturnType<typeof valueScale>;
|
||||
|
||||
/** The tick values the grid and the value axis both draw. */
|
||||
/**
|
||||
* 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);
|
||||
return scale.ticks(Y_TICK_COUNT).filter(Number.isInteger);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +120,7 @@ export function bandPaddingInner(bucketCount: number, plotWidth: number): number
|
||||
return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth);
|
||||
}
|
||||
|
||||
/** The time axis: one band per bucket, in the order the buckets were given. */
|
||||
/** 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,
|
||||
@@ -113,6 +131,19 @@ export function bandScale(values: number[], plot: Plot) {
|
||||
|
||||
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
|
||||
@@ -124,14 +155,43 @@ export function labelTickValues(values: number[], plotWidth: number): number[] {
|
||||
return values.filter((_, i) => i % step === 0);
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
export function formatBucketTime(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
/** 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;
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
|
||||
/**
|
||||
* 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({
|
||||
@@ -140,7 +200,7 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -155,15 +215,8 @@ const styles = stylex.create({
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
tooltip: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: layers.tooltip,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -174,6 +227,12 @@ const styles = stylex.create({
|
||||
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,
|
||||
},
|
||||
@@ -201,7 +260,7 @@ const styles = stylex.create({
|
||||
height: "0.5rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
/** Dynamic: the swatch takes the series colour the SVG shapes are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
@@ -283,7 +342,7 @@ export function ChartFrame({
|
||||
plot: Plot;
|
||||
yScale: ValueScale;
|
||||
yTicks: number[];
|
||||
xScale: BandScale;
|
||||
xScale: TimeScale;
|
||||
xTickValues: number[];
|
||||
bucketSeconds: number;
|
||||
}) {
|
||||
@@ -298,7 +357,7 @@ export function ChartFrame({
|
||||
hideAxisLine
|
||||
hideTicks
|
||||
tickLength={0}
|
||||
tickFormat={(value) => compact.format(Number(value))}
|
||||
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" }}
|
||||
@@ -320,9 +379,9 @@ export function ChartFrame({
|
||||
}
|
||||
|
||||
/**
|
||||
* One segment of a stacked column. The separator is drawn only once the column
|
||||
* is wide enough for two neighbouring segments to read as two shapes; below
|
||||
* that it would be most of the bar.
|
||||
* 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,
|
||||
@@ -330,59 +389,54 @@ export function StackSegment({
|
||||
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}
|
||||
fill={fill}
|
||||
strokeWidth={width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
);
|
||||
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 still selects it.
|
||||
* that pointing at the empty space above a short column or a low point still
|
||||
* selects it.
|
||||
*
|
||||
* A slot is a whole band step, gap included, so that no pixel between two
|
||||
* columns belongs to neither. The last slot is clipped to the plot's right edge:
|
||||
* the band scale spends the trailing gap on nothing, and a full-step rect there
|
||||
* would reach into the right margin.
|
||||
* 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 BucketOverlay({
|
||||
export function HitBands({
|
||||
plot,
|
||||
values,
|
||||
xScale,
|
||||
centers,
|
||||
onEnter,
|
||||
}: {
|
||||
plot: Plot;
|
||||
values: number[];
|
||||
xScale: BandScale;
|
||||
/** The x of each bucket's mark, in bucket order. */
|
||||
centers: number[];
|
||||
onEnter: (index: number) => void;
|
||||
}) {
|
||||
const step = xScale.step();
|
||||
const right = plot.x + plot.width;
|
||||
return (
|
||||
<>
|
||||
{values.map((value, index) => {
|
||||
const x = xScale(value) ?? plot.x;
|
||||
{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={value}
|
||||
x={x}
|
||||
key={index}
|
||||
x={x0}
|
||||
y={plot.y}
|
||||
width={Math.max(0, Math.min(step, right - x))}
|
||||
width={Math.max(0, x1 - x0)}
|
||||
height={plot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => onEnter(index)}
|
||||
@@ -393,13 +447,13 @@ export function BucketOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/** The x a bucket's tooltip points at: the centre of its slot, not of its narrower bar. */
|
||||
/** 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 bar charts, a
|
||||
* 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
|
||||
@@ -444,24 +498,41 @@ export interface TooltipContent {
|
||||
/**
|
||||
* `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.
|
||||
* 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 the tooltip keeps from its anchor point. */
|
||||
const TOOLTIP_OFFSET = 8;
|
||||
/** 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.
|
||||
@@ -469,16 +540,25 @@ export function ChartTooltip({
|
||||
// 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={TOOLTIP_OFFSET}
|
||||
offsetTop={TOOLTIP_OFFSET}
|
||||
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
|
||||
offsetTop={beside ? -height / 2 : CORNER_OFFSET}
|
||||
style={NO_INLINE_STYLE}
|
||||
className={stylex.props(styles.tooltip).className}
|
||||
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) => (
|
||||
@@ -496,6 +576,52 @@ export function ChartTooltip({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
* completion. One request cannot disagree with itself, so those behaviours have
|
||||
* no subject left and are gone rather than ported. What survived the collapse is
|
||||
* pinned below: the three states, and the one rule a single request still does
|
||||
* not settle — that a `keepPreviousData` body from the period the reader left
|
||||
* must never render under the new period's label.
|
||||
* 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";
|
||||
@@ -63,8 +63,8 @@ afterEach(() => vi.unstubAllGlobals());
|
||||
/** The last retry the hook handed out, so a test can spend it. */
|
||||
let lastRetry: () => void;
|
||||
|
||||
function Probe({ period }: { period: Period }) {
|
||||
const panel = useOverviewWindow(period);
|
||||
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"
|
||||
@@ -75,18 +75,18 @@ function Probe({ period }: { period: Period }) {
|
||||
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>,
|
||||
),
|
||||
};
|
||||
@@ -115,13 +115,24 @@ test("a failed request is one error for the whole page, with a retry that refetc
|
||||
expect(calls).toBeGreaterThan(spent);
|
||||
});
|
||||
|
||||
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()).toBe(`ready:24h@${UNTIL}`));
|
||||
|
||||
rerenderWith("1h");
|
||||
// `keepPreviousData` is holding the 24h body. It is a complete answer and
|
||||
// still the wrong one to draw under "1h", so the page waits.
|
||||
// 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("rescoping to a device under the same period waits for that device's body", async () => {
|
||||
const { rerenderWith } = renderProbe("24h");
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
|
||||
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}`));
|
||||
});
|
||||
|
||||
@@ -8,27 +8,27 @@
|
||||
* breakdowns describe the same span and the same database state by construction,
|
||||
* and none of that reconciliation has anything left to reconcile.
|
||||
*
|
||||
* What remains is the one rule a single request does not settle by itself.
|
||||
* `keepPreviousData` holds the body of the period the reader just left — a
|
||||
* complete, self-consistent answer, and still the wrong one to draw under the
|
||||
* new label — so a body is a member of this window only while its own `period`
|
||||
* is the selected one. Until then the page is loading.
|
||||
* 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 } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
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 function useOverviewWindow(period: Period): Panel<Overview> {
|
||||
const query = useQuery({ ...overviewQuery(period), placeholderData: keepPreviousData });
|
||||
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]);
|
||||
|
||||
if (query.isError) return { status: "error", error: query.error, retry };
|
||||
if (query.data !== undefined && query.data.period === period) return { status: "ready", data: query.data };
|
||||
if (query.data !== undefined) return { status: "ready", data: query.data };
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -27,7 +27,7 @@ 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 = [
|
||||
@@ -66,7 +66,7 @@ const styles = stylex.create({
|
||||
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
|
||||
popover: {
|
||||
width: "var(--trigger-width)",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
@@ -45,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.";
|
||||
|
||||
@@ -119,8 +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";
|
||||
|
||||
/** Every Overview panel for one window, from one read transaction. */
|
||||
export const getOverview = (period?: Period): Promise<Overview> => request(`/api/overview${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 })}`);
|
||||
|
||||
@@ -70,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>",
|
||||
|
||||
@@ -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,7 +21,7 @@ import type {
|
||||
export const queryKeys = {
|
||||
health: ["health"] as const,
|
||||
version: ["version"] as const,
|
||||
overview: (period: Period) => ["overview", 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,
|
||||
@@ -48,12 +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 overviewQuery = (period: Period = "24h") =>
|
||||
export const overviewQuery = (period: Period = "24h", client?: string) =>
|
||||
queryOptions({
|
||||
queryKey: queryKeys.overview(period),
|
||||
queryFn: () => api.getOverview(period),
|
||||
queryKey: queryKeys.overview(period, client),
|
||||
queryFn: () => api.getOverview(period, client),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ export interface Health {
|
||||
|
||||
export interface Version {
|
||||
version: string;
|
||||
git_commit: string;
|
||||
zig_version: string;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
|
||||
+16
-28
@@ -40,6 +40,7 @@ import {
|
||||
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,
|
||||
@@ -51,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;
|
||||
@@ -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,17 +161,21 @@ 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. The page reads these with `useQuery` and owns
|
||||
* its own loading and error surface, so awaiting would trade that contract for
|
||||
@@ -177,7 +186,7 @@ const overviewRoute = createRoute({
|
||||
loader: ({ context, deps }) => {
|
||||
const start = (promise: Promise<unknown>) => void promise.catch(() => {});
|
||||
start(context.queryClient.ensureQueryData(healthQuery()));
|
||||
start(context.queryClient.ensureQueryData(overviewQuery(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()));
|
||||
@@ -402,28 +411,7 @@ const systemRoute = createRoute({
|
||||
component: lazyRouteComponent(() => import("@/features/configuration/SystemPage")),
|
||||
});
|
||||
|
||||
// PROTO-OVERVIEW fence start (throwaway — delete with admin/src/proto/)
|
||||
// A dev-only design-exploration route: four full-page Overview variants behind a
|
||||
// floating picker. It hangs off the root rather than the shell, so a variant is
|
||||
// judged as a page and not as the nav around it. `import.meta.env.DEV` is a
|
||||
// literal `false` in a production build, so the array folds to empty and the
|
||||
// dynamic import below is dead code Rollup drops — the proto bytes never reach
|
||||
// dist.
|
||||
const protoRoutes = import.meta.env.DEV
|
||||
? [
|
||||
createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/proto/overview",
|
||||
// No `validateSearch`: the picker owns `?v=` with history.replaceState,
|
||||
// and this route never navigates, so the parameter survives untouched.
|
||||
component: lazyRouteComponent(() => import("@/proto/ProtoOverview")),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
// PROTO-OVERVIEW fence end
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
...protoRoutes,
|
||||
loginRoute,
|
||||
shellRoute.addChildren([
|
||||
indexRoute,
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -33,7 +33,7 @@ const RESPONSES: Record<string, unknown> = {
|
||||
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. */
|
||||
@@ -110,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) {
|
||||
@@ -127,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);
|
||||
@@ -156,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();
|
||||
});
|
||||
@@ -253,16 +253,26 @@ test("Log out sits in the sidebar on wide, and only in the header below it", asy
|
||||
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the header carries no protection display at all any more", async () => {
|
||||
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 () => {
|
||||
|
||||
@@ -9,30 +9,31 @@ import { healthQuery, versionQuery } from "@/lib/queries";
|
||||
import PauseControl from "@/features/pause/PauseControl";
|
||||
import { diagnosticsBadge } from "./diagnosticsBadge";
|
||||
import ConfigStatusNotices from "./ConfigStatusNotices";
|
||||
import SidebarStatus, { ProtectionDot } from "./SidebarStatus";
|
||||
import AuthorityLine from "@/features/configuration/AuthorityLine";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { colors, layers, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
/** The one breakpoint the shell has: below it the sidebar becomes a drawer. */
|
||||
const WIDE = "@media (min-width: 768px)";
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
const WIDE = "@media (min-width: 801px)";
|
||||
|
||||
/**
|
||||
* The four operational surfaces, then the configuration group. Configuration
|
||||
* is a labelled group rather than a collapsible tree: three items do not earn
|
||||
* a disclosure, and a tree would hide the authority line under it.
|
||||
* Two labelled groups (ui-visual-redesign.md): MONITORING is what the resolver
|
||||
* is doing, SYSTEM is what it is told to do and how the box is faring. The
|
||||
* three configuration pages sit flat in SYSTEM — "Configuration" is not one
|
||||
* page — with the authority line under them.
|
||||
*/
|
||||
const NAV_ITEMS = [
|
||||
const MONITORING_ITEMS = [
|
||||
{ to: "/overview", label: "Overview" },
|
||||
{ to: "/activity", label: "Activity" },
|
||||
{ to: "/clients", label: "Clients" },
|
||||
{ to: "/diagnostics", label: "Diagnostics" },
|
||||
] as const;
|
||||
|
||||
const CONFIGURATION_ITEMS = [
|
||||
const SYSTEM_ITEMS = [
|
||||
{ to: "/configuration/protection", label: "Protection" },
|
||||
{ to: "/configuration/resolution", label: "Resolution" },
|
||||
{ to: "/configuration/system", label: "System" },
|
||||
{ to: "/diagnostics", label: "Diagnostics" },
|
||||
] as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
@@ -45,7 +46,8 @@ const styles = stylex.create({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
minHeight: metrics.hitTarget,
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
// Carried by every item, active or not: a weight that changes on
|
||||
@@ -57,16 +59,17 @@ const styles = stylex.create({
|
||||
flex: 1,
|
||||
},
|
||||
navGroup: {
|
||||
marginTop: "1rem",
|
||||
marginTop: "1.125rem",
|
||||
},
|
||||
navGroupLabel: {
|
||||
display: "block",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
paddingBottom: "0.375rem",
|
||||
fontSize: "0.6875rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
letterSpacing: "0.09em",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
@@ -90,12 +93,12 @@ const styles = stylex.create({
|
||||
/** The control sits with the footer, not in the scrolling nav list above it. */
|
||||
sidebarFooter: {
|
||||
paddingInline: "1rem",
|
||||
paddingTop: "0.75rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
/** The current page reads as a filled chip, heavier than the hover fill. */
|
||||
/** The current page is a soft accent-tinted block, in the accent's own colour. */
|
||||
navActive: {
|
||||
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||
color: colors.text,
|
||||
backgroundColor: colors.primarySurface,
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
navIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
@@ -140,16 +143,18 @@ const styles = stylex.create({
|
||||
flexDirection: { default: null, [WIDE]: "column" },
|
||||
minHeight: { default: null, [WIDE]: 0 },
|
||||
overflow: { default: null, [WIDE]: "hidden" },
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
borderRightWidth: 1,
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: colors.border,
|
||||
},
|
||||
brand: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "1rem",
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
paddingBlock: "1.25rem",
|
||||
fontSize: "1rem",
|
||||
lineHeight: "1.5rem",
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.015em",
|
||||
},
|
||||
sidebarNav: {
|
||||
flex: 1,
|
||||
@@ -163,11 +168,19 @@ const styles = stylex.create({
|
||||
minWidth: { default: null, [WIDE]: 0 },
|
||||
flexDirection: "column",
|
||||
},
|
||||
/** Narrow only: on WIDE the sidebar carries everything this row held. */
|
||||
/**
|
||||
* Narrow only: on WIDE the sidebar carries everything this row held. Sticky,
|
||||
* so the brand, the protection state and the menu stay in reach down a long
|
||||
* page.
|
||||
*/
|
||||
header: {
|
||||
display: { default: "flex", [WIDE]: "none" },
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: layers.tooltip,
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
@@ -178,9 +191,10 @@ const styles = stylex.create({
|
||||
display: { default: null, [WIDE]: "none" },
|
||||
},
|
||||
narrowBrand: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
fontSize: "1rem",
|
||||
lineHeight: "1.5rem",
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.015em",
|
||||
display: { default: null, [WIDE]: "none" },
|
||||
},
|
||||
headerRight: {
|
||||
@@ -245,11 +259,28 @@ function NavItem({
|
||||
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const health = useQuery(healthQuery());
|
||||
const badge = diagnosticsBadge(health.data, health.isError);
|
||||
const groupHeadingId = useId();
|
||||
const monitoringId = useId();
|
||||
const systemId = useId();
|
||||
return (
|
||||
<>
|
||||
<ul {...stylex.props(styles.navList)}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<div>
|
||||
{/* A span, not a heading: the sidebar label is not a section of the
|
||||
page, and an h2 here lands in the middle of the page's own outline. */}
|
||||
<span id={monitoringId} {...stylex.props(styles.navGroupLabel)}>
|
||||
Monitoring
|
||||
</span>
|
||||
<ul aria-labelledby={monitoringId} {...stylex.props(styles.navList)}>
|
||||
{MONITORING_ITEMS.map((item) => (
|
||||
<NavItem key={item.to} to={item.to} label={item.label} onNavigate={onNavigate} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div {...stylex.props(styles.navGroup)}>
|
||||
<span id={systemId} {...stylex.props(styles.navGroupLabel)}>
|
||||
System
|
||||
</span>
|
||||
<ul aria-labelledby={systemId} {...stylex.props(styles.navList)}>
|
||||
{SYSTEM_ITEMS.map((item) => (
|
||||
<NavItem
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -259,17 +290,6 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div {...stylex.props(styles.navGroup)}>
|
||||
{/* A span, not a heading: the sidebar label is not a section of the
|
||||
page, and an h2 here lands in the middle of the page's own outline. */}
|
||||
<span id={groupHeadingId} {...stylex.props(styles.navGroupLabel)}>
|
||||
Configuration
|
||||
</span>
|
||||
<ul aria-labelledby={groupHeadingId} {...stylex.props(styles.navList)}>
|
||||
{CONFIGURATION_ITEMS.map((item) => (
|
||||
<NavItem key={item.to} to={item.to} label={item.label} onNavigate={onNavigate} />
|
||||
))}
|
||||
</ul>
|
||||
<AuthorityLine />
|
||||
</div>
|
||||
</>
|
||||
@@ -279,7 +299,8 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
/**
|
||||
* The sidebar's foot, in both renderings. Pause is a runtime action on the whole
|
||||
* resolver rather than on the page in front of the reader, which is why it sits
|
||||
* with the version label instead of in the header of every route.
|
||||
* with the status block and the version label instead of in the header of every
|
||||
* route.
|
||||
*/
|
||||
function SidebarFooter() {
|
||||
return (
|
||||
@@ -287,6 +308,7 @@ function SidebarFooter() {
|
||||
<div {...stylex.props(styles.sidebarFooter)}>
|
||||
<PauseControl />
|
||||
</div>
|
||||
<SidebarStatus />
|
||||
<VersionFooter />
|
||||
</>
|
||||
);
|
||||
@@ -296,7 +318,7 @@ function VersionFooter() {
|
||||
const { data } = useQuery(versionQuery());
|
||||
return (
|
||||
<footer {...stylex.props(styles.versionFooter)}>
|
||||
{data === undefined ? "nxdns" : `nxdns v${data.version} (${data.git_commit.slice(0, 7)})`}
|
||||
{data === undefined ? "nxdns" : `nxdns v${data.version}`}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -353,6 +375,7 @@ export default function AppShell() {
|
||||
</Button>
|
||||
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
|
||||
<div {...stylex.props(styles.headerRight)}>
|
||||
<ProtectionDot />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The compact status block at the sidebar's foot (milestone 39): whether
|
||||
* filtering is in force, how fast queries are arriving, and how long the
|
||||
* process has been up. Three readings the operator glances at from any page,
|
||||
* each from data the shell already holds — no request of its own.
|
||||
*
|
||||
* Queries/min derives from the Overview body the page in front of the reader
|
||||
* is drawing, so it appears only while that page is open and its window has
|
||||
* landed; on every other page the row is absent rather than stale.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMatch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatDuration, formatRate } from "@/lib/format";
|
||||
import { overviewQuery, versionQuery } from "@/lib/queries";
|
||||
import { useProtection } from "@/features/pause/protection";
|
||||
import { DEFAULT_PERIOD } from "@/features/overview/period";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
block: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.4375rem",
|
||||
marginInline: "1rem",
|
||||
marginBottom: "0.75rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.875rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
line: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.625rem",
|
||||
margin: 0,
|
||||
},
|
||||
key: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4375rem",
|
||||
},
|
||||
value: {
|
||||
fontWeight: 550,
|
||||
color: colors.text,
|
||||
},
|
||||
dot: {
|
||||
flexShrink: 0,
|
||||
width: "7px",
|
||||
height: "7px",
|
||||
borderRadius: "999px",
|
||||
},
|
||||
dotActive: { backgroundColor: colors.chartGreen },
|
||||
dotPaused: { backgroundColor: colors.warnBorderStrong },
|
||||
dotUnavailable: { backgroundColor: colors.chartRed },
|
||||
dotUnknown: { backgroundColor: colors.borderStrong },
|
||||
/** The narrow header's rendering: the dot and the state alone, inline. */
|
||||
inline: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4375rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
const PROTECTION_TEXT = {
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
unavailable: "Unavailable",
|
||||
unknown: "Unknown",
|
||||
} as const;
|
||||
|
||||
const DOT_STYLE = {
|
||||
active: styles.dotActive,
|
||||
paused: styles.dotPaused,
|
||||
unavailable: styles.dotUnavailable,
|
||||
unknown: styles.dotUnknown,
|
||||
} as const;
|
||||
|
||||
/** The dot and the word, for the narrow header where there is room for nothing more. */
|
||||
export function ProtectionDot() {
|
||||
const { state } = useProtection();
|
||||
return (
|
||||
<span {...stylex.props(styles.inline)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.dot, DOT_STYLE[state])} />
|
||||
<span>Protection {PROTECTION_TEXT[state].toLowerCase()}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uptime as of now, not as of the fetch: the version answer is a minute old at
|
||||
* most, so the seconds it reported are aged by the time since it landed. A
|
||||
* restart shows within that minute. Ticks every second, because the first
|
||||
* minute reads in seconds.
|
||||
*/
|
||||
function useUptimeSeconds(): number | null {
|
||||
const version = useQuery(versionQuery());
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
if (version.data === undefined) return null;
|
||||
return version.data.uptime_seconds + Math.max(0, now - version.dataUpdatedAt) / 1000;
|
||||
}
|
||||
|
||||
function useQueriesPerMinute(): number | null {
|
||||
const match = useMatch({ from: "/shell/overview", shouldThrow: false });
|
||||
const period = match?.search.period ?? DEFAULT_PERIOD;
|
||||
// Reads the body the page fetched; never fetches one of its own. A failed
|
||||
// refetch leaves the last body in the cache, and the page is then showing
|
||||
// its error, not that body: the row goes with it.
|
||||
const overview = useQuery({ ...overviewQuery(period, match?.search.client), enabled: false });
|
||||
if (match === undefined || overview.isError || overview.data === undefined) return null;
|
||||
if (overview.data.period !== period) return null;
|
||||
const minutes = (overview.data.until - overview.data.since) / 60;
|
||||
return minutes > 0 ? overview.data.totals.queries / minutes : null;
|
||||
}
|
||||
|
||||
export default function SidebarStatus() {
|
||||
const { state } = useProtection();
|
||||
const rate = useQueriesPerMinute();
|
||||
const uptime = useUptimeSeconds();
|
||||
return (
|
||||
<dl aria-label="Status" {...stylex.props(styles.block)}>
|
||||
<div {...stylex.props(styles.line)}>
|
||||
<dt {...stylex.props(styles.key)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.dot, DOT_STYLE[state])} />
|
||||
Protection
|
||||
</dt>
|
||||
<dd {...stylex.props(styles.value)}>{PROTECTION_TEXT[state]}</dd>
|
||||
</div>
|
||||
{rate !== null && (
|
||||
<div {...stylex.props(styles.line)}>
|
||||
<dt>Queries/min</dt>
|
||||
<dd {...stylex.props(styles.value, shared.tabularNums)}>{formatRate(rate)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div {...stylex.props(styles.line)}>
|
||||
<dt>Uptime</dt>
|
||||
<dd {...stylex.props(styles.value, shared.tabularNums)}>
|
||||
{uptime === null ? "—" : formatDuration(uptime)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,15 @@ test("open episodes are the count, warnings and errors together", () => {
|
||||
expect(badge).toEqual({ text: "3", label: "3 active diagnostic events" });
|
||||
});
|
||||
|
||||
/** Four digits of open episodes read as one number, grouped like every other count. */
|
||||
test("a four-figure count is grouped", () => {
|
||||
const badge = diagnosticsBadge(
|
||||
health({ diagnostics: { state: "recording", active_warnings: 1200, active_errors: 34 } }),
|
||||
false,
|
||||
);
|
||||
expect(badge).toEqual({ text: "1,234", label: "1,234 active diagnostic events" });
|
||||
});
|
||||
|
||||
test("one open episode is counted in the singular", () => {
|
||||
const badge = diagnosticsBadge(
|
||||
health({ diagnostics: { state: "recording", active_warnings: 0, active_errors: 1 } }),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import type { Health } from "@/lib/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
export interface NavBadge {
|
||||
/** What the badge shows. Shape and text, never colour alone. */
|
||||
@@ -30,7 +31,10 @@ export function diagnosticsBadge(health: Health | undefined, pollFailed: boolean
|
||||
if (health === undefined) return null;
|
||||
const open = health.diagnostics.active_warnings + health.diagnostics.active_errors;
|
||||
if (open > 0) {
|
||||
return { text: String(open), label: `${open} active diagnostic ${open === 1 ? "event" : "events"}` };
|
||||
return {
|
||||
text: formatCount(open),
|
||||
label: `${formatCount(open)} active diagnostic ${open === 1 ? "event" : "events"}`,
|
||||
};
|
||||
}
|
||||
if (health.status === "degraded") return { text: "!", label: "Health degraded" };
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The one panel chrome (milestone 39): a white hairline-bordered card with a
|
||||
* prominent title and a one-line description under it. Every panel on every
|
||||
* page is one of these, so the pages agree by construction rather than by
|
||||
* copying a `panel` style object around.
|
||||
*
|
||||
* The title is an `h2` because a card is a section of the page's outline; a
|
||||
* page that needs a different level passes `as`.
|
||||
*/
|
||||
|
||||
import { useId } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { colors, metrics } from "./tokens.stylex";
|
||||
|
||||
export const cardStyles = stylex.create({
|
||||
card: {
|
||||
minWidth: 0,
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
padding: metrics.cardPadding,
|
||||
},
|
||||
head: {
|
||||
marginBottom: "1.25rem",
|
||||
},
|
||||
/**
|
||||
* The card's title is the loudest text on the page after the stat numerals:
|
||||
* the references (Pi-hole, NextDNS) both lead each panel with a heading the
|
||||
* eye lands on first. Kept in one place so no heading reset outranks it.
|
||||
*/
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: "1.4rem",
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 650,
|
||||
letterSpacing: "-0.015em",
|
||||
color: colors.text,
|
||||
textWrap: "balance",
|
||||
},
|
||||
description: {
|
||||
margin: 0,
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
textWrap: "pretty",
|
||||
},
|
||||
});
|
||||
|
||||
export default function Card({
|
||||
title,
|
||||
description,
|
||||
as: Heading = "h2",
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
/** One sentence saying what the panel shows; every card carries one (the decision record's card head). */
|
||||
description: string;
|
||||
as?: "h2" | "h3";
|
||||
/** Extra styles for the card box itself: a grid placement, a clipped list variant. */
|
||||
style?: stylex.StyleXStyles;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
return (
|
||||
<section aria-labelledby={titleId} {...stylex.props(cardStyles.card, style)}>
|
||||
<div {...stylex.props(cardStyles.head)}>
|
||||
<Heading id={titleId} {...stylex.props(cardStyles.title)}>
|
||||
{title}
|
||||
</Heading>
|
||||
<p {...stylex.props(cardStyles.description)}>{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { colors, layers } from "./tokens.stylex";
|
||||
import { colors, layers, metrics } from "./tokens.stylex";
|
||||
import { styles as shared } from "./styles";
|
||||
|
||||
interface Props {
|
||||
@@ -77,7 +77,7 @@ const styles = stylex.create({
|
||||
},
|
||||
dangerButton: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.danger,
|
||||
color: colors.primaryText,
|
||||
|
||||
+25
-5
@@ -22,7 +22,7 @@ import {
|
||||
SelectValue,
|
||||
Text,
|
||||
} from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
import { colors, metrics } from "./tokens.stylex";
|
||||
import { styles as shared } from "./styles";
|
||||
|
||||
export interface SelectOption {
|
||||
@@ -45,9 +45,10 @@ interface Props {
|
||||
description?: string;
|
||||
/**
|
||||
* `field` matches a full-width form input, `compactField` the smaller one a
|
||||
* dialog uses, `inline` a control sitting in a row of other controls.
|
||||
* dialog uses, `inline` a control sitting in a row of other controls, and
|
||||
* `toolbar` the 44px hairline dropdown a page's scope picker is.
|
||||
*/
|
||||
variant?: "field" | "compactField" | "inline";
|
||||
variant?: "field" | "compactField" | "inline" | "toolbar";
|
||||
/** Visible but inert, keeping its value on screen; RAC also drops it from the tab order. */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
@@ -77,6 +78,25 @@ const styles = stylex.create({
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
/** Rectangular, hairline, full hit height: the decision record's toolbar selector. */
|
||||
toolbar: {
|
||||
// A button keeps its content width even as a flex container, so without
|
||||
// this the scope picker's 11rem wrapper shows as dead space beside it.
|
||||
width: "100%",
|
||||
minHeight: metrics.hitTarget,
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: { default: colors.border, ":hover": colors.borderStrong },
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
paddingInline: "0.9375rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
transitionProperty: "border-color",
|
||||
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
|
||||
},
|
||||
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
|
||||
value: {
|
||||
overflow: "hidden",
|
||||
@@ -97,7 +117,7 @@ const styles = stylex.create({
|
||||
width: "var(--trigger-width)",
|
||||
maxHeight: "16rem",
|
||||
overflowY: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -143,7 +163,7 @@ export default function Select({
|
||||
variant = "field",
|
||||
isDisabled = false,
|
||||
}: Props) {
|
||||
const base = variant === "field" ? shared.input : shared.smallInput;
|
||||
const base = variant === "field" ? shared.input : variant === "toolbar" ? styles.toolbar : shared.smallInput;
|
||||
const block = variant === "compactField" ? styles.compact : null;
|
||||
return (
|
||||
<AriaSelect
|
||||
|
||||
+12
-11
@@ -47,7 +47,7 @@ export const styles = stylex.create({
|
||||
input: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -57,7 +57,7 @@ export const styles = stylex.create({
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
smallInput: {
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -73,7 +73,7 @@ export const styles = stylex.create({
|
||||
minHeight: 40,
|
||||
...press,
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -90,7 +90,7 @@ export const styles = stylex.create({
|
||||
smallButton: {
|
||||
...press,
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -102,7 +102,7 @@ export const styles = stylex.create({
|
||||
largeButton: {
|
||||
...press,
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -114,7 +114,7 @@ export const styles = stylex.create({
|
||||
primaryButton: {
|
||||
...press,
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
@@ -128,7 +128,7 @@ export const styles = stylex.create({
|
||||
largePrimaryButton: {
|
||||
...press,
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
@@ -141,7 +141,7 @@ export const styles = stylex.create({
|
||||
rowButton: {
|
||||
...press,
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
paddingInline: "0.5rem",
|
||||
@@ -177,7 +177,7 @@ export const styles = stylex.create({
|
||||
...press,
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.dangerBorder,
|
||||
@@ -216,7 +216,7 @@ export const styles = stylex.create({
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
@@ -264,6 +264,7 @@ export const styles = stylex.create({
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
/** Focusable: a table wider than a phone scrolls sideways, and a keyboard must be able to reach the scroll. */
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
@@ -281,7 +282,7 @@ export const styles = stylex.create({
|
||||
gap: "0.75rem",
|
||||
marginTop: "1rem",
|
||||
maxWidth: "32rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
|
||||
@@ -41,11 +41,21 @@ export const colors = stylex.defineVars({
|
||||
* chart legend — where that loss shows.
|
||||
*/
|
||||
textSecondary: { default: "oklch(44.2% 0.017 285.786)", [DARK]: "oklch(70.5% 0.015 286.067)" },
|
||||
/** Primary actions. Identical in both schemes, as before the conversion. */
|
||||
primary: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
|
||||
/**
|
||||
* The accent (ui-visual-redesign.md): blue at hue 258 for nav, links, primary
|
||||
* actions and the total-queries series. Identical in both schemes.
|
||||
*/
|
||||
primary: { default: "oklch(55% 0.16 258)", [DARK]: "oklch(55% 0.16 258)" },
|
||||
primaryText: { default: "#fff", [DARK]: "#fff" },
|
||||
/** Primary as foreground: lightened in dark so it clears the ground. */
|
||||
primaryOnSurface: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(70.7% 0.165 254.624)" },
|
||||
/**
|
||||
* Primary as foreground text. Darker than `primary` in light because the
|
||||
* active nav label sits on `primarySurface`, where the accent itself
|
||||
* measures 4.39:1; this value measures 5.43:1 there. Lightened in dark so it
|
||||
* clears the ground (5.87:1 on its wash).
|
||||
*/
|
||||
primaryOnSurface: { default: "oklch(50% 0.16 258)", [DARK]: "oklch(72% 0.13 258)" },
|
||||
/** The accent as a wash: the active nav item's fill. */
|
||||
primarySurface: { default: "oklch(96% 0.02 258)", [DARK]: "oklch(28% 0.05 258)" },
|
||||
/**
|
||||
* Warnings: a degraded condition the operator can still act on, as against
|
||||
* `danger`, which is a failure or a destructive action. The amber ramp.
|
||||
@@ -59,8 +69,44 @@ export const colors = stylex.defineVars({
|
||||
dangerSurface: { default: "oklch(97.1% 0.013 17.38)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||
dangerBorder: { default: "oklch(80.8% 0.114 19.571)", [DARK]: "oklch(44.4% 0.177 26.899)" },
|
||||
dangerText: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||
/**
|
||||
* The softer red the charts and the blocked stat numeral wear: 3.54:1 on
|
||||
* white, which clears the 3:1 floor for non-text and large text and nothing
|
||||
* else. Small text keeps `dangerText`.
|
||||
*/
|
||||
chartRed: { default: "oklch(65% 0.19 25)", [DARK]: "oklch(65% 0.19 25)" },
|
||||
/** Cache and success: fixed semantics, never derived from the accent. */
|
||||
chartGreen: { default: "oklch(62% 0.14 150)", [DARK]: "oklch(62% 0.14 150)" },
|
||||
chartGreenSurface: { default: "oklch(93% 0.05 150)", [DARK]: "oklch(30% 0.06 150)" },
|
||||
/**
|
||||
* The categorical series palette (ui-visual-redesign.md): eight hues spread
|
||||
* round the wheel, clear of the reserved red at hue 25, the same in both
|
||||
* schemes. `seriesOther` is the aggregated tail, a flat gray that never
|
||||
* competes with a named client. SVG attributes take these vars directly.
|
||||
*/
|
||||
seriesBlue: { default: "oklch(60% 0.14 258)", [DARK]: "oklch(60% 0.14 258)" },
|
||||
seriesTeal: { default: "oklch(68% 0.12 190)", [DARK]: "oklch(68% 0.12 190)" },
|
||||
seriesViolet: { default: "oklch(62% 0.15 300)", [DARK]: "oklch(62% 0.15 300)" },
|
||||
seriesAmber: { default: "oklch(75% 0.13 80)", [DARK]: "oklch(75% 0.13 80)" },
|
||||
seriesGreen: { default: "oklch(65% 0.13 150)", [DARK]: "oklch(65% 0.13 150)" },
|
||||
seriesMagenta: { default: "oklch(66% 0.14 340)", [DARK]: "oklch(66% 0.14 340)" },
|
||||
seriesOrange: { default: "oklch(70% 0.14 55)", [DARK]: "oklch(70% 0.14 55)" },
|
||||
seriesOlive: { default: "oklch(72% 0.11 120)", [DARK]: "oklch(72% 0.11 120)" },
|
||||
seriesOther: { default: "oklch(80% 0.01 260)", [DARK]: "oklch(80% 0.01 260)" },
|
||||
/**
|
||||
* The query-types ring: the accent's hue stepped in lightness and chroma from
|
||||
* the busiest type outward. Six steps cover every ring the API answers with
|
||||
* in practice; in dark the ramp runs the other way so the busiest stays the
|
||||
* most saturated against the ground.
|
||||
*/
|
||||
ramp1: { default: "oklch(55% 0.15 258)", [DARK]: "oklch(72% 0.14 258)" },
|
||||
ramp2: { default: "oklch(63% 0.13 258)", [DARK]: "oklch(64% 0.12 258)" },
|
||||
ramp3: { default: "oklch(71% 0.10 258)", [DARK]: "oklch(56% 0.10 258)" },
|
||||
ramp4: { default: "oklch(78% 0.075 258)", [DARK]: "oklch(48% 0.08 258)" },
|
||||
ramp5: { default: "oklch(85% 0.05 258)", [DARK]: "oklch(41% 0.06 258)" },
|
||||
ramp6: { default: "oklch(91% 0.035 258)", [DARK]: "oklch(35% 0.04 258)" },
|
||||
/** The focus ring colour. The ring itself is a floor, not a variant. */
|
||||
focus: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
|
||||
focus: { default: "oklch(55% 0.16 258)", [DARK]: "oklch(55% 0.16 258)" },
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -90,6 +136,10 @@ export const layers = stylex.defineVars({
|
||||
export const metrics = stylex.defineConsts({
|
||||
/** WCAG 2.5.5's enhanced 44px target, applied where layout permits; some inline controls stop at 40px or above 2.5.8's 24px minimum. */
|
||||
hitTarget: "44px",
|
||||
/** One corner radius for every surface: cards, controls, tooltips, nav items. */
|
||||
radius: "4px",
|
||||
/** The inset of a card's content from its border. */
|
||||
cardPadding: "1.75rem",
|
||||
/** The press and hover settle shared by every control that styles its own states. */
|
||||
transitionProperty: "background-color, color, border-color, transform",
|
||||
transitionDuration: "120ms",
|
||||
|
||||
@@ -43,7 +43,6 @@ pub fn build(b: *std.Build) void {
|
||||
// the default, so `zig build` and `zig build test` need no flag.
|
||||
const version_option = b.option([]const u8, "version-string", "Version reported by `nxdns version` (required by `dist`)");
|
||||
const version_string = version_option orelse "0.1.0-dev";
|
||||
const git_commit = b.option([]const u8, "git-commit", "Git commit reported by `nxdns version`") orelse "unknown";
|
||||
const admin_dist = b.option(
|
||||
[]const u8,
|
||||
"admin-dist",
|
||||
@@ -77,7 +76,6 @@ pub fn build(b: *std.Build) void {
|
||||
options.addOption(bool, "integration", integration);
|
||||
options.addOption(bool, "live", live);
|
||||
options.addOption([]const u8, "version_string", version_string);
|
||||
options.addOption([]const u8, "git_commit", git_commit);
|
||||
options.addOption([]const u8, "zig_version_string", builtin.zig_version_string);
|
||||
options.addOption([]const u8, "contract_samples_out", contract_samples_out);
|
||||
|
||||
@@ -318,11 +316,39 @@ pub fn build(b: *std.Build) void {
|
||||
// disk, so the test binary has to run at the build root.
|
||||
cut_tests_run.setCwd(b.path("."));
|
||||
test_step.dependOn(&cut_tests_run.step);
|
||||
// The test binary analyses only what the tests reference; `main` and the
|
||||
// stages behind it are compiled here so a type error in the cut itself
|
||||
// fails `zig build test` and not the release.
|
||||
test_step.dependOn(&cut_tool.step);
|
||||
|
||||
// `dist_stage` owns the release archive bytes, and its reproducibility is
|
||||
// the property the flake pins depend on, so it is tested like any other
|
||||
// decision this build makes.
|
||||
const dist_stage_tests = b.addTest(.{
|
||||
.name = "dist-stage-tool",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("tools/dist_stage.zig"),
|
||||
.target = b.graph.host,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(dist_stage_tests).step);
|
||||
|
||||
// `gen_admin_assets` decides which files reach the binary, and that set must
|
||||
// not depend on the bundle's provenance, so it is tested here too.
|
||||
const gen_admin_assets_tests = b.addTest(.{
|
||||
.name = "gen-admin-assets-tool",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("tools/gen_admin_assets.zig"),
|
||||
.target = b.graph.host,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(gen_admin_assets_tests).step);
|
||||
|
||||
addDist(b, options, admin_assets, .{
|
||||
.version = version_option,
|
||||
.version_string = version_string,
|
||||
.git_commit = git_commit,
|
||||
.admin_dist = admin_dist,
|
||||
});
|
||||
}
|
||||
@@ -355,14 +381,18 @@ const DistOptions = struct {
|
||||
/// from one that happens to equal the default.
|
||||
version: ?[]const u8,
|
||||
version_string: []const u8,
|
||||
git_commit: []const u8,
|
||||
admin_dist: []const u8,
|
||||
};
|
||||
|
||||
/// `dist` builds everything releasable; `verify-dist` asserts the result.
|
||||
/// Both run on a laptop exactly as they run on the runner, which is the point:
|
||||
/// release checks that only exist in CI shell are the brittleness milestone 14
|
||||
/// set out to remove.
|
||||
/// `dist` builds everything releasable; `verify-dist` asserts the result;
|
||||
/// `pin-flake` writes the resulting hashes into `flake.nix` and `verify-pins`
|
||||
/// asserts that they still describe the bytes under `zig-out/dist`.
|
||||
///
|
||||
/// All four run on a laptop exactly as they run on the runner, which is the
|
||||
/// point: release checks that only exist in CI shell are the brittleness
|
||||
/// milestone 14 set out to remove. The pins depend on it twice over — the cut
|
||||
/// writes them here and CI recomputes them there, and the two only agree
|
||||
/// because it is one build graph rather than two scripts.
|
||||
fn addDist(
|
||||
b: *std.Build,
|
||||
options: *std.Build.Step.Options,
|
||||
@@ -371,11 +401,15 @@ fn addDist(
|
||||
) void {
|
||||
const dist_step = b.step("dist", "Build the release tarballs, checksums and staged payloads");
|
||||
const verify_step = b.step("verify-dist", "Verify the release artifacts under zig-out/dist");
|
||||
const pin_step = b.step("pin-flake", "Write the release hashes under zig-out/dist into flake.nix");
|
||||
const verify_pins_step = b.step("verify-pins", "Check flake.nix pins the hashes of the release under zig-out/dist");
|
||||
|
||||
if (distPreflight(b, dist_options)) |problem| {
|
||||
const fail = b.addFail(problem);
|
||||
dist_step.dependOn(&fail.step);
|
||||
verify_step.dependOn(&fail.step);
|
||||
pin_step.dependOn(&fail.step);
|
||||
verify_pins_step.dependOn(&fail.step);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,7 +435,6 @@ fn addDist(
|
||||
verify_run.addArgs(&.{ "--dist-dir", b.getInstallPath(.prefix, "dist") });
|
||||
verify_run.addArgs(&.{ "--work-dir", b.getInstallPath(.prefix, "dist-verify") });
|
||||
verify_run.addArgs(&.{ "--version", dist_options.version_string });
|
||||
verify_run.addArgs(&.{ "--git-commit", dist_options.git_commit });
|
||||
verify_run.addArg("--zon");
|
||||
verify_run.addFileArg(b.path("build.zig.zon"));
|
||||
verify_run.addArgs(&.{ "--max-bytes", b.fmt("{d}", .{max_binary_bytes}) });
|
||||
@@ -447,36 +480,19 @@ fn addDist(
|
||||
stage_run.addArg("--licenses");
|
||||
stage_run.addDirectoryArg(staged_licenses);
|
||||
|
||||
// Two commands, never one: `addSystemCommand` executes argv directly
|
||||
// and does not interpret `|`, and a shell pipeline without `pipefail`
|
||||
// would report only gzip's status while a failed tar passed silently.
|
||||
const tar_run = b.addSystemCommand(&.{
|
||||
"tar",
|
||||
"--format=gnu",
|
||||
"--sort=name",
|
||||
"--mtime=@0",
|
||||
"--owner=0",
|
||||
"--group=0",
|
||||
"--numeric-owner",
|
||||
"-c",
|
||||
"-f",
|
||||
});
|
||||
setReproducibleEnv(tar_run);
|
||||
const tar_file = tar_run.addOutputFileArg(b.fmt("{s}.tar", .{name}));
|
||||
tar_run.addArg("-C");
|
||||
// The tarball is written by our own tool rather than by the runner's
|
||||
// `tar` and `gzip`: the release hashes are pinned in `flake.nix` before
|
||||
// CI rebuilds them, so the bytes may depend on the staged tree and on
|
||||
// nothing else the host supplies.
|
||||
const archive_run = b.addRunArtifact(stage_tool);
|
||||
archive_run.addArg("archive");
|
||||
archive_run.addArg("--root");
|
||||
// The staged payload is the sole entry of its cache directory, so its
|
||||
// parent is what `-C` needs and declaring it declares the payload.
|
||||
tar_run.addDirectoryArg(staged.dirname());
|
||||
tar_run.addArg(name);
|
||||
|
||||
// `-n` is required because `--mtime=@0` normalises the tar member times
|
||||
// but not the timestamp gzip writes into its own header. `-c` is
|
||||
// required because plain `gzip <file>` rewrites its input in place, and
|
||||
// the input here is a content-addressed cache entry.
|
||||
const gzip_run = b.addSystemCommand(&.{ "gzip", "-n", "-9", "-c" });
|
||||
setReproducibleEnv(gzip_run);
|
||||
gzip_run.addFileArg(tar_file);
|
||||
const tarball = gzip_run.captureStdOut(.{ .basename = b.fmt("{s}.tar.gz", .{name}) });
|
||||
// parent is what `--root` needs and declaring it declares the payload.
|
||||
archive_run.addDirectoryArg(staged.dirname());
|
||||
archive_run.addArgs(&.{ "--payload", name });
|
||||
archive_run.addArg("--out");
|
||||
const tarball = archive_run.addOutputFileArg(b.fmt("{s}.tar.gz", .{name}));
|
||||
|
||||
const install_binary = b.addInstallFile(
|
||||
staged.path(b, "nxdns"),
|
||||
@@ -513,6 +529,38 @@ fn addDist(
|
||||
|
||||
verify_run.step.dependOn(dist_step);
|
||||
verify_step.dependOn(&verify_run.step);
|
||||
|
||||
// The pins in `flake.nix` are written before CI ever builds the release, so
|
||||
// the run that rebuilds it has to prove they describe its own bytes.
|
||||
//
|
||||
// It is a step of its own and NOT part of `verify-dist`. An ordinary commit
|
||||
// between two cuts builds the `build.zig.zon` version from a tree that
|
||||
// differs from the released one, so its bytes never match the pins and
|
||||
// checking them there would fail every such build. The two CI jobs that may
|
||||
// not skip it call it by name: the package job on the bump commit, and the
|
||||
// publish job unconditionally before any upload.
|
||||
const pin_check_run = b.addRunArtifact(stage_tool);
|
||||
pin_check_run.has_side_effects = true;
|
||||
pin_check_run.addArg("pin-check");
|
||||
pin_check_run.addArgs(&.{ "--sums", b.getInstallPath(.prefix, "dist/SHA256SUMS") });
|
||||
pin_check_run.addArg("--flake");
|
||||
pin_check_run.addFileArg(b.path("flake.nix"));
|
||||
pin_check_run.addArgs(&.{ "--version", dist_options.version_string });
|
||||
pin_check_run.step.dependOn(dist_step);
|
||||
verify_pins_step.dependOn(&pin_check_run.step);
|
||||
|
||||
// The write half, run by the cut and by nothing else. `flake.nix` is named
|
||||
// as a plain path rather than a `LazyPath`: this run edits the source file
|
||||
// in place, and a file argument would declare it an input of a step that is
|
||||
// in fact its author.
|
||||
const pin_run = b.addRunArtifact(stage_tool);
|
||||
pin_run.has_side_effects = true;
|
||||
pin_run.addArg("pin");
|
||||
pin_run.addArgs(&.{ "--sums", b.getInstallPath(.prefix, "dist/SHA256SUMS") });
|
||||
pin_run.addArgs(&.{ "--flake", b.pathFromRoot("flake.nix") });
|
||||
pin_run.addArgs(&.{ "--version", dist_options.version_string });
|
||||
pin_run.step.dependOn(dist_step);
|
||||
pin_step.dependOn(&pin_run.step);
|
||||
}
|
||||
|
||||
/// The one message `dist` and `verify-dist` fail with when the release inputs
|
||||
@@ -564,13 +612,6 @@ fn hostTool(b: *std.Build, name: []const u8) *std.Build.Step.Compile {
|
||||
});
|
||||
}
|
||||
|
||||
/// Locale and time zone leak into archive metadata and into tool output.
|
||||
/// Pinning both is the cheap half of reproducibility (milestone-14 ruling 12).
|
||||
fn setReproducibleEnv(run: *std.Build.Step.Run) void {
|
||||
run.setEnvironmentVariable("LC_ALL", "C");
|
||||
run.setEnvironmentVariable("TZ", "UTC");
|
||||
}
|
||||
|
||||
/// Milestone-15 ruling 4: every `*.zig` under `src/` must appear in
|
||||
/// `src/tests.zig` as a line that trims to exactly `_ = @import("<path>");`,
|
||||
/// where `<path>` is relative to `src/`. Whole-line equality, not a substring
|
||||
@@ -744,7 +785,10 @@ fn addExecutable(
|
||||
exe.root_module.addAnonymousImport("admin_assets", .{ .root_source_file = admin_assets });
|
||||
exe.root_module.linkLibrary(sqliteLibrary(b, target, optimize));
|
||||
exe.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize));
|
||||
exe.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") });
|
||||
exe.root_module.addCSourceFile(.{
|
||||
.file = b.path("src/platform/mbedtls_shim.c"),
|
||||
.flags = &.{filePrefixMap(b, .build_root)},
|
||||
});
|
||||
addMbedtlsThreadingMacros(exe.root_module);
|
||||
return exe;
|
||||
}
|
||||
@@ -810,6 +854,8 @@ fn sqliteLibrary(
|
||||
"-DSQLITE_THREADSAFE=1",
|
||||
"-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1",
|
||||
"-DSQLITE_OMIT_LOAD_EXTENSION",
|
||||
filePrefixMap(b, .build_root),
|
||||
filePrefixMap(b, .global_cache),
|
||||
},
|
||||
});
|
||||
return lib;
|
||||
@@ -846,19 +892,34 @@ fn mbedtlsLibrary(
|
||||
lib.root_module.addIncludePath(dep.path(include_dir));
|
||||
}
|
||||
|
||||
const c_flags = [_][]const u8{ filePrefixMap(b, .build_root), filePrefixMap(b, .global_cache) };
|
||||
lib.root_module.addCSourceFiles(.{
|
||||
.root = dep.path("library"),
|
||||
.files = &mbedtls_library_sources,
|
||||
.flags = &c_flags,
|
||||
});
|
||||
lib.root_module.addCSourceFiles(.{
|
||||
.root = dep.path("3rdparty"),
|
||||
.files = &mbedtls_3rdparty_sources,
|
||||
.flags = &c_flags,
|
||||
});
|
||||
lib.installHeadersDirectory(dep.path("include/mbedtls"), "mbedtls", .{});
|
||||
lib.installHeadersDirectory(dep.path("include/psa"), "psa", .{});
|
||||
return lib;
|
||||
}
|
||||
|
||||
/// `__FILE__` in the C sources (mbedTLS debug and assertion macros) would
|
||||
/// otherwise embed the absolute checkout path into the release binary, and the
|
||||
/// flake pins require the bytes to be the same on every machine that builds
|
||||
/// the tag. Both roots a dependency can be stored under are mapped.
|
||||
fn filePrefixMap(b: *std.Build, root: enum { build_root, global_cache }) []const u8 {
|
||||
const path = switch (root) {
|
||||
.build_root => b.build_root.path orelse ".",
|
||||
.global_cache => b.graph.global_cache_root.path orelse ".",
|
||||
};
|
||||
return b.fmt("-ffile-prefix-map={s}=.", .{path});
|
||||
}
|
||||
|
||||
/// Context sizes change with threading enabled, so every compilation unit that
|
||||
/// includes mbedTLS headers (the library itself and `mbedtls_shim.c`) must see
|
||||
/// the same macros. Concurrent handshakes share `ssl_config`, the CTR-DRBG, and
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
.{
|
||||
.name = .nxdns,
|
||||
.version = "0.0.15",
|
||||
.version = "0.0.21",
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x3307b311dded1d91,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The binary is NOT compiled here. Build it first, from the repository root:
|
||||
#
|
||||
# (cd admin && npm ci && npm run build)
|
||||
# zig build dist -Dversion-string=<V> -Dgit-commit=<SHA> \
|
||||
# zig build dist -Dversion-string=<V> \
|
||||
# -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
|
||||
#
|
||||
# then build the image with the repository root as context:
|
||||
|
||||
@@ -22,6 +22,7 @@ Steps for a goal you already have. They assume you know what nxdns is.
|
||||
- [how-to/verify-a-release.md](how-to/verify-a-release.md) — check the signature and the checksums before you run anything, and what they prove.
|
||||
- [how-to/install-with-systemd.md](how-to/install-with-systemd.md) — a real install as a system service, including the Raspberry Pi 5 aarch64 binary.
|
||||
- [how-to/install-with-docker.md](how-to/install-with-docker.md) — the published container image and the compose file.
|
||||
- [how-to/install-with-nix.md](how-to/install-with-nix.md) — the flake input pinned to a release tag, and Renovate for tag bumps.
|
||||
- [how-to/upgrade.md](how-to/upgrade.md) — move to a new release without losing state.
|
||||
- [how-to/troubleshoot.md](how-to/troubleshoot.md) — what to do when it does not answer, does not block, or will not start.
|
||||
- [how-to/enable-doh-and-dot.md](how-to/enable-doh-and-dot.md) — serve encrypted DNS with certificates.
|
||||
|
||||
@@ -11,6 +11,7 @@ pub const tutorial_first_run_md = @embedFile("tutorial/first-run.md");
|
||||
pub const howto_back_up_and_restore_md = @embedFile("how-to/back-up-and-restore.md");
|
||||
pub const howto_enable_doh_and_dot_md = @embedFile("how-to/enable-doh-and-dot.md");
|
||||
pub const howto_install_with_docker_md = @embedFile("how-to/install-with-docker.md");
|
||||
pub const howto_install_with_nix_md = @embedFile("how-to/install-with-nix.md");
|
||||
pub const howto_install_with_systemd_md = @embedFile("how-to/install-with-systemd.md");
|
||||
pub const howto_measure_performance_md = @embedFile("how-to/measure-performance.md");
|
||||
pub const howto_set_up_admin_authentication_md = @embedFile("how-to/set-up-admin-authentication.md");
|
||||
@@ -34,6 +35,7 @@ pub const pages: []const Page = &.{
|
||||
.{ .path = "docs/how-to/back-up-and-restore.md", .text = howto_back_up_and_restore_md },
|
||||
.{ .path = "docs/how-to/enable-doh-and-dot.md", .text = howto_enable_doh_and_dot_md },
|
||||
.{ .path = "docs/how-to/install-with-docker.md", .text = howto_install_with_docker_md },
|
||||
.{ .path = "docs/how-to/install-with-nix.md", .text = howto_install_with_nix_md },
|
||||
.{ .path = "docs/how-to/install-with-systemd.md", .text = howto_install_with_systemd_md },
|
||||
.{ .path = "docs/how-to/measure-performance.md", .text = howto_measure_performance_md },
|
||||
.{ .path = "docs/how-to/set-up-admin-authentication.md", .text = howto_set_up_admin_authentication_md },
|
||||
|
||||
@@ -220,7 +220,7 @@ The Dockerfile does not compile anything. It assembles a filesystem around binar
|
||||
```sh
|
||||
(cd admin && npm ci && npm run build)
|
||||
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
|
||||
DOCKER_BUILDKIT=1 docker build -t nxdns -f deploy/docker/Dockerfile .
|
||||
```
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Install nxdns with Nix
|
||||
|
||||
Adds nxdns to a NixOS machine as a flake input pinned to a release tag. At the end `pkgs`-style references to `inputs.nxdns.packages.${system}.default` resolve to the published release binary, and a Renovate custom manager opens a pull request when a new tag appears.
|
||||
|
||||
nxdns publishes its own `flake.nix`. Its packages do not build nxdns from source: each one fetches the release tarball for the target and pins its SHA-256 hash, so a changed byte fails the build. The two supported systems are `aarch64-linux` and `x86_64-linux`, both static musl builds that need nothing on the host.
|
||||
|
||||
For the signature and checksum checks a human does once, see [verify a release](verify-a-release.md). For what each configuration field means, see [the configuration reference](../reference/configuration.md).
|
||||
|
||||
> Verification: the commands in step 1 and step 2 were run on the machine that wrote this page, against the flake at nxdns 0.0.16. `nix flake check --no-build` passed and `nix build` produced a binary that printed `nxdns 0.0.16`. The consumer snippets in step 3 and step 4 are copied from the shape `rpi.mial.net` already uses for another flake input of the same author; they were not evaluated from this checkout, which is not a NixOS configuration.
|
||||
|
||||
## 1. Add the input, pinned to a tag
|
||||
|
||||
In the consuming flake:
|
||||
|
||||
```nix
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
nxdns = {
|
||||
url = "git+https://git.mial.net/mokhtar/nxdns.git?ref=refs/tags/v0.0.16";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Pin a tag, not a branch. A branch pin moves the version under you at the next `nix flake update`, and the hashes in the flake belong to whatever release that branch last cut.
|
||||
|
||||
The `follows` line is not cosmetic. Without it Nix fetches and evaluates nxdns's own nixpkgs as a second nixpkgs, which costs a download and an evaluation for a package that only needs `stdenv`, `fetchurl`, and `lib`.
|
||||
|
||||
Write the lock entry:
|
||||
|
||||
```sh
|
||||
nix flake lock
|
||||
```
|
||||
|
||||
## 2. Check what you pinned
|
||||
|
||||
```sh
|
||||
nix flake check --no-build
|
||||
nix eval .#packages.x86_64-linux.default.outPath
|
||||
```
|
||||
|
||||
Evaluation does not fetch the tarball. The download happens at build time, and the hash in nxdns's flake is what the build asserts the bytes against.
|
||||
|
||||
## 3. Use the package in a NixOS configuration
|
||||
|
||||
Pass the flake inputs to the module system, then reference the package:
|
||||
|
||||
```nix
|
||||
{ inputs, pkgs, ... }:
|
||||
{
|
||||
environment.systemPackages = [ inputs.nxdns.packages.${pkgs.stdenv.hostPlatform.system}.default ];
|
||||
}
|
||||
```
|
||||
|
||||
The derivation installs `bin/nxdns`, plus `LICENSE` and `THIRD-PARTY-NOTICES` under `share/doc/nxdns`. It sets `meta.mainProgram`, so `lib.getExe` resolves to the binary. Run it as a service with the unit from [install with systemd](install-with-systemd.md), or write your own module around it.
|
||||
|
||||
## 4. Let Renovate bump the tag
|
||||
|
||||
Renovate's built-in `nix` manager does not do this. It only advances `flake.lock` along the ref an input already tracks, and a `refs/tags/vX.Y.Z` ref never moves, so it reports no releases for a tag-pinned input. Verified on 2026-09-09 with Renovate 42.99.0 against a Gitea host: all flake inputs, GitHub-hosted ones included, came back with an empty release list.
|
||||
|
||||
What works is a regex custom manager that treats the tag in the input URL as a version string, with the `gitea-tags` datasource:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^flake\\.nix$/"],
|
||||
"matchStrings": ["git\\+https://git\\.mial\\.net/mokhtar/nxdns\\.git\\?ref=refs/tags/(?<currentValue>v\\d+\\.\\d+\\.\\d+)"],
|
||||
"depNameTemplate": "mokhtar/nxdns",
|
||||
"datasourceTemplate": "gitea-tags",
|
||||
"registryUrlTemplate": "https://git.mial.net",
|
||||
"versioningTemplate": "semver"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Verified on 2026-09-09: with this manager, Renovate opened the pull request for v0.0.18 on the day of the tag. That pull request rewrites the tag in `flake.nix` only. `flake.lock` still records the old revision, so the same pull request must also refresh the lock, either with a `postUpgradeTasks` command (`nix flake update nxdns`, which needs the self-hosted `allowedCommands` setting) or with a CI job on the Renovate branch that commits the lock. The datasource proposes only tags that already exist, so it never pins a release that has not been cut.
|
||||
|
||||
Between the tag push and the asset upload there is a window in which the tag exists and the release tarballs do not. A pin written by hand during that window evaluates, then fails at build time with a 404 from the release download URL. Wait for the release to be published, or re-run the build once it is. Renovate's own pull requests are not affected by the window in practice, because it runs on a schedule rather than on the tag push.
|
||||
|
||||
## Upgrading
|
||||
|
||||
Change the `?ref=refs/tags/vX.Y.Z` in the input, run `nix flake lock --update-input nxdns`, and rebuild. Read the release notes first: nxdns is pre-0.1 and breaks on purpose, and [upgrade](upgrade.md) lists what state a version change touches.
|
||||
@@ -377,13 +377,13 @@ Requires Zig 0.16.0 and Node.js. From the repository root:
|
||||
```sh
|
||||
(cd admin && npm ci && npm run build)
|
||||
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
|
||||
```
|
||||
|
||||
The first command builds the admin interface into `admin/dist`; the last one embeds that directory in the binary. Build the interface every time, before the binary: a stale `admin/dist` ships an admin UI that does not match the API it talks to. `dist` refuses to run against the `admin/dist-placeholder` default for exactly that reason, so there is no way to skip it by accident.
|
||||
|
||||
`-Dversion-string` is required and has no default. It is what `nxdns version` prints. Take it from `build.zig.zon` rather than inventing one: `verify-dist` asserts that the version under build equals `.version` there, so a made-up string like `0.0.0-local` builds but then fails verification. `-Dgit-commit` is what distinguishes your build from the published one of the same version.
|
||||
`-Dversion-string` is required and has no default. It is what `nxdns version` prints. Take it from `build.zig.zon` rather than inventing one: `verify-dist` asserts that the version under build equals `.version` there, so a made-up string like `0.0.0-local` builds but then fails verification.
|
||||
|
||||
What comes out under `zig-out/dist/` is the same set a release publishes, minus the signature and the image digest:
|
||||
|
||||
@@ -397,7 +397,7 @@ The two targets are `x86_64-linux-musl` and `aarch64-linux-musl`. Both binaries
|
||||
Check the result the same way the release pipeline does:
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ OK: no problems found
|
||||
>
|
||||
> ```
|
||||
> $ nxdns version
|
||||
> nxdns <version> (unknown)
|
||||
> nxdns <version>
|
||||
> zig 0.16.0
|
||||
> ```
|
||||
>
|
||||
@@ -338,13 +338,13 @@ If you are running something you built rather than a release, step 2 is a build
|
||||
```sh
|
||||
(cd admin && npm ci && npm run build)
|
||||
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
|
||||
```
|
||||
|
||||
Rebuild `admin/dist` before the binary on every upgrade. The admin interface is embedded at build time, and an old bundle against a new API is a broken System page. `dist` refuses the `admin/dist-placeholder` default outright, so the only way to ship a stale bundle is to leave an old `admin/dist` in place.
|
||||
|
||||
The staged payload for each target is under `zig-out/dist/stage/nxdns-<version>-<triple>/`, and step 3 continues from there with that path in place of the extracted one. The version string has to equal `.version` in `build.zig.zon` — `verify-dist` asserts it, so a made-up one builds and then fails verification. What tells your build apart from the published release of the same version is `-Dgit-commit`, which `nxdns version` prints beside the version.
|
||||
The staged payload for each target is under `zig-out/dist/stage/nxdns-<version>-<triple>/`, and step 3 continues from there with that path in place of the extracted one. The version string has to equal `.version` in `build.zig.zon` — `verify-dist` asserts it, so a made-up one builds and then fails verification. Your build reports the same two lines as the published release of that version, so compare the tarball hashes rather than the `nxdns version` output when you need to tell them apart.
|
||||
|
||||
Under Docker, build the image and name it instead of pulling:
|
||||
|
||||
|
||||
@@ -197,13 +197,11 @@ tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
|
||||
./nxdns-$VERSION-x86_64-linux-musl/nxdns version
|
||||
```
|
||||
|
||||
`version` prints the version and the git commit it was built from, then the Zig version. The version has to match the tag you downloaded, and the commit has to match the commit the tag points at.
|
||||
`version` prints the version, then the Zig version the binary was built with. The version has to match the tag you downloaded. The binary carries no commit sha, so the check that ties a release to its source is the rebuild below rather than a string in this output.
|
||||
|
||||
> Verified against `v0.0.1`: both tarballs listed exactly the one directory and
|
||||
> six files with the stated modes, no symlinks and no absolute or `..` paths,
|
||||
> and the extracted binary printed `nxdns 0.0.1
|
||||
> (3c2d0d41f04570038e805b759da4541e198eae17)` — the commit `v0.0.1` points at —
|
||||
> then `zig 0.16.0`.
|
||||
> and the extracted binary printed `nxdns 0.0.1` then `zig 0.16.0`.
|
||||
|
||||
## 6. Verify the container image
|
||||
|
||||
@@ -262,22 +260,42 @@ It proves two things:
|
||||
|
||||
It does not prove that the binary in the tarball was built from the source in this repository. The machine that ran the build also held the signing key, so a compromise of that machine produces an artifact that is signed, verifies cleanly, and contains whatever the attacker put in it. The signature is a statement about origin and integrity in transit. It is not a statement about provenance from source.
|
||||
|
||||
Closing that gap needs a reproducibility gate — an independent build, run somewhere else, that lands on the same bytes — and this project does not have one. It is a recorded deferral, not an oversight: see `specs/milestone-14.md` ruling 12. Until it exists, nothing here claims the build is reproducible, because nobody has measured whether it is.
|
||||
Closing that gap needs a reproducible build — an independent build, run somewhere else, that lands on the same bytes. The release tarballs are reproducible: the binary carries no commit sha, the archive is written by the project's own tool with fixed modes, zero timestamps and sorted entries, and CI rebuilds the same bytes and compares them against the pinned hashes before it uploads anything. The rebuild below is how you check that for yourself.
|
||||
|
||||
The signing key is a subkey rather than the primary key, which limits the damage of the case above: a leaked release subkey is revoked on its own and the identity, the commit signatures and everyone's existing trust in the key survive.
|
||||
|
||||
## Rebuild it yourself
|
||||
|
||||
You can still build the same version from source and compare. That gets you a binary whose provenance you know, and the comparison is worth making — read the paragraph after the recipe before you draw a conclusion from it.
|
||||
Build the same version from source and compare the hashes. Match the toolchain first: `ZIG_VERSION`, `NODE_VERSION` and `NPM_VERSION` at the top of `.gitea/workflows/gates.yml` are the exact versions the release used, and a different patch release of any of them changes the bytes.
|
||||
|
||||
Zig has to be the official ziglang.org tarball for that version, not your distribution's package of it. A distro package of `0.16.0` is built against the system LLVM and emits different machine code than the official build, and both print `0.16.0`, so the version string tells you nothing. This is not hypothetical: the first cut of `flake.nix` pinned hashes CI could not reproduce for exactly that reason, and the release cut now downloads the official tarball itself rather than trusting `PATH`. Fetch and verify it the same way, against the `ZIG_TARBALL_SHA256` pin that sits next to `ZIG_VERSION` in `gates.yml` (CI installs the same bytes through setup-zig, which checks them by minisign):
|
||||
|
||||
```sh
|
||||
git clone https://git.mial.net/mokhtar/nxdns
|
||||
cd nxdns
|
||||
git checkout "v$VERSION"
|
||||
git verify-tag "v$VERSION"
|
||||
(cd admin && npm ci && npm run build)
|
||||
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \
|
||||
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
|
||||
|
||||
ZIG_VERSION=$(sed -n 's/^ ZIG_VERSION: "\(.*\)"$/\1/p' .gitea/workflows/gates.yml)
|
||||
ZIG_TARBALL_SHA256=$(sed -n 's/^ ZIG_TARBALL_SHA256: "\(.*\)"$/\1/p' .gitea/workflows/gates.yml)
|
||||
curl -fsSL -o ../zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-x86_64-linux-$ZIG_VERSION.tar.xz"
|
||||
(cd .. && echo "$ZIG_TARBALL_SHA256 zig.tar.xz" | sha256sum -c - && tar -xJf zig.tar.xz)
|
||||
ZIG="$(cd .. && pwd)/zig-x86_64-linux-$ZIG_VERSION/zig"
|
||||
"$ZIG" version
|
||||
```
|
||||
|
||||
The host matters too: the admin bundle is built with host-native Rolldown and Lightning CSS bindings, which the lockfile ships per platform and libc, so the recipe reproduces the release only on x86_64 Linux with glibc, the runner CI uses. The two `npm_config_*` paths must not exist on your machine; the recipe relies on npm finding no config file there. The environment matters as much as the toolchain. The release cut and CI both build the bundle under exactly nine variables and a `022` umask, so the recipe below does the same: a locale, a time zone, a build timestamp or a file mode picked up from your shell each move the bytes. The Zig build gets a private `--cache-dir` so a stale local cache cannot leak into them; the global Zig cache stays shared, because it is content-addressed and a fresh one refetches every dependency (see `specs/release-cut.md`).
|
||||
|
||||
```sh
|
||||
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 'cd admin && umask 022 && npm ci && npm run build'
|
||||
|
||||
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 && exec "'"$ZIG"'" build dist \
|
||||
-Dversion-string="'"$VERSION"'" \
|
||||
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe \
|
||||
--cache-dir "$(mktemp -d)"'
|
||||
|
||||
sha256sum zig-out/dist/nxdns-"$VERSION"-*.tar.gz
|
||||
```
|
||||
|
||||
@@ -285,20 +303,13 @@ sha256sum zig-out/dist/nxdns-"$VERSION"-*.tar.gz
|
||||
|
||||
`zig build dist` writes `zig-out/dist/`: the two tarballs, a staging directory per target under `stage/`, the stripped binaries under `bin/<triple>/`, and a `SHA256SUMS` covering the two tarballs. The published `SHA256SUMS.txt` is that file with a third line for `IMAGE-DIGEST.txt` appended by the release job, so the two tarball lines should match and the local file has no third line to compare.
|
||||
|
||||
Now the caveat, and it is the whole reason this section is last. **A hash that differs does not mean the release was tampered with.** Nothing in this project measures whether two builds of the same commit on two different machines produce the same bytes, and there are several ordinary reasons they would not: a different Zig patch release, a different Node version, a different path to the build directory, a different npm lockfile resolution. A hash that matches is real evidence. A hash that does not match tells you only that something about the two builds differed, and finding out what is on you.
|
||||
The tarball is written by `zig build dist` itself rather than by the host's `tar` and `gzip`, so its layout is fixed and a rebuild on a matching toolchain reproduces it byte for byte. Entries are sorted by their full path as bytes, with the payload directory first. The payload directory and `nxdns` carry mode `0755`; every other file carries `0644`. Every entry has a zero modification time, uid 0, gid 0, and no user or group name. The gzip wrapper carries no original filename and a zero header timestamp, which is what makes two archives of the same tree compare equal.
|
||||
|
||||
If you want the comparison to mean as much as it can, match the toolchain the release used. The Zig version is the second line of `nxdns version`, and both it and the Node version are pinned to exact patch releases at the top of `.gitea/workflows/gates.yml`, which is the workflow the release runs.
|
||||
Compare your two tarball hashes against two things: the published `SHA256SUMS.txt`, and the `hashes` block of `flake.nix` at the tag, which carries the same digests in SRI form. All three agree on a matching toolchain, and the release pipeline fails before it uploads anything if they do not.
|
||||
|
||||
> Verified against `v0.0.1`, and the result is the caveat above in action. The
|
||||
> whole recipe ran from a fresh clone: `git verify-tag v0.0.1` printed
|
||||
> `Good signature` under the same signing subkey as the release, and
|
||||
> `zig build dist` produced both tarballs. The hashes did **not** match the
|
||||
> published `SHA256SUMS.txt` — the binaries themselves already differ. The Zig
|
||||
> version matched the pin exactly; the Node version did not (24.14.1 against
|
||||
> the pinned 24.19.0) and the build path differed, two of the ordinary causes
|
||||
> listed above. That is a measurement of what an unpinned rebuild gives you,
|
||||
> not evidence of tampering: the signature, checksum and image checks earlier
|
||||
> on this page all passed against the same release.
|
||||
A hash that differs is a signal to check the toolchain and the environment first. A distribution's Zig package instead of the official tarball is the first thing to rule out; an unpinned Node or npm version is the next ordinary explanation, and a build run outside the normalized environment above is the next one. Rule both out before you conclude anything about the release itself.
|
||||
|
||||
> Verified against `v0.0.1`, before the build was reproducible: the recipe ran from a fresh clone, `git verify-tag v0.0.1` printed `Good signature` under the release subkey, and the rebuilt tarball hashes did not match the published `SHA256SUMS.txt` (Node 24.14.1 against the pinned 24.19.0, a different build path, and an archive written by the host's `tar`). Releases from 0.0.17 on are built and checked by the pinned pipeline this page describes, and the cut records the local hashes in `flake.nix` before CI rebuilds them.
|
||||
|
||||
## If a check fails
|
||||
|
||||
|
||||
@@ -202,7 +202,12 @@ If a restart and an import race for the write lock, one of them simply wins: bot
|
||||
|
||||
## `version`
|
||||
|
||||
Prints two lines: the nxdns version with the git commit, then the Zig version the binary was built with. Takes no flags and no arguments.
|
||||
Prints two lines: the nxdns version, then the Zig version the binary was built with. Takes no flags and no arguments.
|
||||
|
||||
```
|
||||
nxdns <version>
|
||||
zig 0.16.0
|
||||
```
|
||||
|
||||
## `help`
|
||||
|
||||
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1788752844,
|
||||
"narHash": "sha256-VaWGJ6+cIYN2erfSecbRV+4ljI185Ty2wUrXyvQbgOw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "dc5d91f840324650bac8c379428c7037a416959a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
description = "nxdns: DNS sinkhole with per-client policy groups";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs =
|
||||
{ nixpkgs, ... }:
|
||||
let
|
||||
# `zig build cut` rewrites the lines between the delimiters by text replacement.
|
||||
# BEGIN GENERATED BY zig build cut
|
||||
version = "0.0.21";
|
||||
hashes = {
|
||||
"aarch64-linux" = "sha256-KXRsmmi3wHwfib4ha2ulmnUPfWd2d4FIKxAFWFZ/Ga8=";
|
||||
"x86_64-linux" = "sha256-JmFcS1LLweYEaLxAhJvgJbQhTKpaK33N4Khtp8qztM4=";
|
||||
};
|
||||
# END GENERATED BY zig build cut
|
||||
|
||||
triples = {
|
||||
"aarch64-linux" = "aarch64-linux-musl";
|
||||
"x86_64-linux" = "x86_64-linux-musl";
|
||||
};
|
||||
|
||||
systems = builtins.attrNames hashes;
|
||||
|
||||
package =
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
lib = pkgs.lib;
|
||||
triple = triples.${system};
|
||||
in
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "nxdns";
|
||||
inherit version;
|
||||
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://git.mial.net/mokhtar/nxdns/releases/download/v${version}/nxdns-${version}-${triple}.tar.gz";
|
||||
hash = hashes.${system};
|
||||
};
|
||||
|
||||
# Static musl binary: the install check asserts that no interpreter was patched in.
|
||||
dontPatchELF = true;
|
||||
dontStrip = true;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 nxdns $out/bin/nxdns
|
||||
install -Dm644 LICENSE $out/share/doc/nxdns/LICENSE
|
||||
install -Dm644 THIRD-PARTY-NOTICES $out/share/doc/nxdns/THIRD-PARTY-NOTICES
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
doInstallCheck = true;
|
||||
|
||||
# GNU readelf prints `INTERP`, not `PT_INTERP`; `${READELF:?}` keeps an unset variable from turning the grep into a pass.
|
||||
installCheckPhase = ''
|
||||
runHook preInstallCheck
|
||||
|
||||
# Captured first: piping readelf straight into grep hides its exit
|
||||
# status, so a readelf that failed to read the file at all would
|
||||
# print nothing, match nothing, and pass as "static".
|
||||
if ! segments="$(''${READELF:?} -l "$out/bin/nxdns")"; then
|
||||
echo "readelf -l failed on $out/bin/nxdns" >&2
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$segments" | grep -q INTERP; then
|
||||
echo "nxdns has an INTERP segment: it is dynamically linked, not static" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! dynamic="$(''${READELF:?} -d "$out/bin/nxdns")"; then
|
||||
echo "readelf -d failed on $out/bin/nxdns" >&2
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$dynamic" | grep -q NEEDED; then
|
||||
echo "nxdns has DT_NEEDED entries: it links against shared libraries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! reported="$($out/bin/nxdns version)"; then
|
||||
echo "nxdns version exited non-zero: the binary does not run here" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prefix match: releases before 0.0.17 print a commit sha after the version.
|
||||
case "$reported" in
|
||||
"nxdns ${version}"*) echo "nxdns version reports: $reported" ;;
|
||||
*)
|
||||
echo "nxdns version reports '$reported'; expected it to start with 'nxdns ${version}'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
runHook postInstallCheck
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "DNS sinkhole with per-client policy groups";
|
||||
homepage = "https://git.mial.net/mokhtar/nxdns";
|
||||
license = lib.licenses.eupl12;
|
||||
mainProgram = "nxdns";
|
||||
platforms = systems;
|
||||
sourceProvenance = [ lib.sourceTypes.binaryNativeCode ];
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = nixpkgs.lib.genAttrs systems (system: {
|
||||
default = package system;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -810,3 +810,39 @@ The implementation matches the spec with these review-driven refinements (three
|
||||
- `dot_client.zig`: handshake `ReadFailed`/`WriteFailed` unwrap the stream's stored cause through `transport.mapLocal` first, and certificate-bundle `OutOfMemory` is a local resource, not a peer fault.
|
||||
- `transport.zig`: `Endpoint.parse` rejects userinfo/query/fragment delimiters (`@`, `?`, `#`) in the authority, and `?`/`#` in a DoH path.
|
||||
- `dot_client.zig` (follow-up, tls_name commit): `DotClient.init` takes a `tls_name`; it is the SNI and certificate-verification name, while the dial target stays `endpoint.host`. Empty keeps the endpoint host, which is the behavior described above. The same follow-up fixed a send bug this file had from the start, invisible until a DoT handshake first succeeded: `tls.Client.flush` only encrypts into the socket writer's buffer and never flushes it, so the query never left the process and the peer eventually closed the connection (`ReceiveFailed`/`EndOfStream`). `TlsStream.flush` now does both flushes and `exchange` calls it; a hermetic loopback test in `tls_client_integration_test.zig` covers it.
|
||||
|
||||
## Addendum (2026-09-12): an upstream episode follows health, and a peer fault carries its cause
|
||||
|
||||
Observed on the Pi over three days (nxdns 0.0.17 to 0.0.19): 20 `upstream.exchange` warning episodes, 17 with one occurrence, 15 resolved within ten seconds; since the 0.0.20 restart 1229 successes, 0 failures, 401 silent stale-session redials. `Pool.recordFailure` reports an episode on every failed exchange and `recordSuccess` resolves it on the next success, while `health.State` trips backoff only at `failure_threshold` consecutive failures. Two rules over one failure stream, and the Diagnostics page shows the looser one. Second gap: the episode detail is `upstream 'tls://1.1.1.1:853' failed: SendFailed`; the DoT client unwraps the concrete cause into `Transact.Failure.cause` and then returns a bare `PeerFault` error to the pool, so the cause is lost before anything records it. Codex reviewed the design (thread 01a09648) and its eight findings are folded in below.
|
||||
|
||||
### 1. Health owns "failing"; the episode is a projection of its transitions
|
||||
|
||||
- `health.State.recordFailure(at, fault, cfg, rand)` and `recordSuccess(at, cfg)` return an `Effect`: `{ revision: u64, state: union(enum) { clear, tripped: Fault } }`. `revision` is a per-state counter incremented by every mutation. The effect is the complete desired state of the episode after the mutation, never a transition: `tripped` when `consecutive_failures >= cfg.failure_threshold` (the predicate line 142 already uses, exposed as `tripped(cfg)`; not `available()`, which turns true when a backoff expires before recovery is proven), `clear` otherwise. The fault in `tripped` is the effective last fault the state holds after the mutation, not the incoming one: a stale failure (case 1 at `recordFailure`) never displaces a newer cause, so the episode detail cannot regress to an older one. A tagged union, not an action enum with a payload field, so a report without a fault is unrepresentable.
|
||||
- The pool projects every effect onto the store: `tripped` reports, `clear` resolves. A resolve when nothing is open issues no SQL (`storage/events.zig`), so the steady state costs nothing. Because every effect carries the whole desired state, last-writer-wins by revision is correct: the pool applies effects per entry in revision order under a separate `diagnostics_mutex` with a per-entry `applied_revision`, and drops an effect whose revision is below the applied one. Codex's reordering case (a failure trips health, a newer success clears it and its resolve reaches the store first, then the delayed failure arrives) is dropped instead of opening an episode nothing will resolve; and the converse cases, where a `none` transition would have overtaken a required report or resolve, cannot occur because there is no `none`. Health mutates under `Pool.mutex` as today and the effect is computed there; the store call runs after that mutex is released. Lock order is pool mutex, then nothing; diagnostics mutex, then store mutex. No task holds the pool mutex while it takes either of the other two.
|
||||
- Projection also reconciles a latched store failure for what an entry owns: a `clear` that failed to write is retried by the next `clear` the entry projects, and a `tripped` by the next failure. What no entry owns is handled by the reconciliation points below.
|
||||
- A success that leaves the state tripped (a stale success behind a newer failure, health case 1) projects nothing: `recordSuccess` returns `?Effect`, null in that case. The card is already open from the failure that tripped it, a report on a success would count a successful exchange as an occurrence, and a no-op that advanced the revision would reintroduce the dropped-report hole. Such a success does not touch `applied_revision`.
|
||||
- Reconciliation at the two points revision order cannot reach. (1) At boot, once the first generation is built: `store.resolveExcept(.upstream_exchange, kept = the enabled urls of the pool)` closes persisted episodes for upstreams that are gone, disabled or failed to build, and `Pool.reconcile(io)` projects every entry's current effect, which on a fresh pool resolves the rest. A re-asserted `tripped` must not count as an occurrence: the store gains `ensureOpen`, which opens the episode with the detail when none is active and otherwise leaves `occurrences` and `last_seen` untouched; `reconcile` uses it, a recorded failure keeps using `report`. (2) At every retirement of a displaced generation, both the pinned path (`Owner.release` at `refs == 0`, the point after which no exchange of it can still project) and the idle path (`Owner.replace` handing a zero-ref generation back to the caller), the same two calls run against the current generation. One owner function does the retire-and-reconcile and both paths call it, so a third retirement site cannot forget it. A removed or disabled upstream's episode is closed at the next reconciliation point; if the store had latched a failure at that moment, the next reconciliation point retries it. Before this addendum such an episode was never closed at all. `resolveExcept` is the right call for this code: the pool is the only reporter of `upstream_exchange`, unlike `configuration.load`, whose scoped rule in owner.zig stays. The previous "one exchange of lag" claim is withdrawn; the retirement point is exact.
|
||||
- Occurrences are the count of applied reports. Under concurrent reordering an older report behind a newer one is dropped, so the count can undercount; the health counters on `/metrics` are exact. Stated, not fixed: exact occurrence counts would need the store to accept out-of-order increments, and nobody reads the count as a metric.
|
||||
- The store folds a repeated report into the open episode (`occurrences`, `last_seen`, detail), so failures during backoff raise counts on one card. A warning card now means exactly what the pool means: it stopped trusting this endpoint.
|
||||
- Boundary, stated on purpose: an endpoint failing every other exchange never trips, because every newest success clears the count, so it opens no episode and `/api/health` stays `ok`. The rolling success rate on `/metrics` shows it. Diagnostics is for episodes, not chronic rates; a second predicate for that is not added.
|
||||
- No new configuration. The threshold that exists is the threshold, and it is at least 1: `health.Config` is validated where it is constructed (a comptime check on the default; if it is user-configurable, the config loader refuses 0), because a tripped state with no recorded failure has no fault to report.
|
||||
|
||||
### 2. A peer fault is a value between the leaf client and the pool
|
||||
|
||||
- `transport.zig` gains `Fault = struct { kind: PeerFault, cause: anyerror }`, `Outcome = union(enum) { reply: []u8, fault: Fault }`, and a leaf interface `Leaf` with `exchangeFn(ptr, io, query, response_buf) (LocalResource || Cancellation)!Outcome`. `kind` is the taxonomy as today; `cause` is the concrete unwrapped error (`BrokenPipe`, `ConnectionResetByPeer`, `TlsAlert`, `HttpConnectionClosing`, ...). No phase field: the taxonomy already names it for every kind that has one, and `TlsFailed` cannot say where it failed. No HTTP status number for `HttpStatus`; the claim is limited to concrete I/O causes. No optional metadata bag.
|
||||
- `dot_client.zig` and `doh_client.zig` implement `Leaf`; a `Transact.Failure` becomes a returned `Fault` instead of a thrown error. The DoT stale-session redial is unchanged and still counted through `reuse_recoveries`, never as a fault. The retry list is not widened; with the cause recorded, a stale-session cause outside the four lifecycle errors shows in the next episode and the list grows on evidence.
|
||||
- `Pool` consumes `Leaf` for its slots and keeps implementing `transport.Client` toward the handler, the forward client, and the fakes that sit on that side. The outer error set, `transport.group`, and the handler are untouched; the pool returns `last_fault.kind` as the error it returns today. The race harness stays generic: the pool races the leaf through a function whose error set adds `Timeout`; a leaf never returns `error.Timeout` itself (its own timeouts are faults), so `error.Timeout` out of the race is the harness. With `race == .expired and !truncated` the pool synthesizes `Fault{ .kind = error.Timeout, .cause = error.Timeout }` and records it: an untruncated attempt expiry is peer evidence. With `truncated` it stays `error.BudgetExhausted`, unattributed, exactly as today.
|
||||
- Health stores the effective fault (kind and cause names, `error_name_capacity` sized for both). Every surface that printed the taxonomy prints one text: `<Kind> (cause <Cause>)`, e.g. `SendFailed (cause BrokenPipe)`. Those surfaces are the episode detail, `upstream 'tls://1.1.1.1:853' failed: SendFailed (cause BrokenPipe)`, `Snapshot.last_error` and its one reader, the `nxdns check` FAIL line, and the pool's debug `AttemptFailure` line. `/api/health`, `/api/upstreams` and `/metrics` carry no fault text today and gain none here.
|
||||
- `transport.raceUntilTagged` becomes generic over the raced function's own error set: it returns `RacedError(f) || error{ Timeout, SystemResources, Canceled }`, so the pool's attempt wrapper returns `LeafError || error{Timeout}` with no `@errorCast` and no `unreachable`; `error.Timeout` out of the race is the harness by type, which sets `race = .expired` before returning it; the loop's catch asserts that and handles it in the expiry branch only, so a regression of the harness contract traps in ReleaseSafe instead of blaming an endpoint. The compiler enforces at the race boundary that a leaf cannot throw a `PeerFault` or `BudgetExhausted`. A leaf's own timeout is a returned `Fault{ .kind = error.Timeout, .cause = <leaf cause> }` with `race == .completed`, and a pool test proves it is recorded with the leaf's cause and never confused with the harness expiry.
|
||||
- The blocklist fetcher's `last_failure` side field stays as it is; same shape, other subsystem, its own addendum.
|
||||
|
||||
### Tests
|
||||
|
||||
- health: one failure returns `clear`; the second returns `tripped`; a success on a tripped state returns `clear`; a stale success behind a newer failure projects nothing; a stale failure returns the newer effective fault; revisions strictly increase.
|
||||
- pool: a `tripped` effect delivered after a newer `clear` effect is dropped and no episode is open afterwards; a `clear` delivered after a newer `tripped` is dropped and the episode stays open; an episode open in the store before the pool exists is resolved by boot reconciliation, one for a url the pool does not have and one for a url it has; a retired generation's late `tripped` is corrected by the reconciliation at its last release, and the idle-replace path reconciles too; a reconcile of a tripped entry with an open episode moves neither `occurrences` nor `last_seen`; a stale success while tripped projects nothing and the occurrence count does not move; one failed exchange opens no episode and the second opens one that the next success resolves; a truncated expiry records nothing and returns `BudgetExhausted`; an untruncated expiry records `Timeout (cause Timeout)`; a leaf fault of kind `Timeout` is recorded with the leaf's cause.
|
||||
- clients: the DoT integration tests assert the returned `Fault` with kind and cause, not the kind alone; a DoH integration test drives `DohClient.exchange` against a loopback port that is bound but never listens and stays bound for the test's duration, so the connect is refused deterministically with no port reuse race, and asserts the cause survives to the returned `Fault`; the stub-connection helper tests keep their causes.
|
||||
- surfaces: the `nxdns check` test asserts the `<Kind> (cause <Cause>)` text.
|
||||
|
||||
### Out of scope
|
||||
|
||||
An idle timer on the DoT session, a chronic-rate predicate, the fetcher migration, and any change to the DoT retry list.
|
||||
|
||||
@@ -199,3 +199,11 @@ Deviations the build kept, judged defensible in review:
|
||||
- `Certificate.Bundle` has no in-memory PEM entry point in 0.16.0; the loopback test mirrors `addCertsFromFile`'s decode+parse calls to preload the fixture cert.
|
||||
- The S2.4 probe-close criterion is pinned as far as the repo can observe it: the failed-probe path runs the per-iteration deferred `close` (cli test, honestly named), and close-after-failure/double-close safety is pinned in the DoT integration tests. Close after a *successful* probe is unreachable in-repo (the probe verifies against the system trust store, and the only in-repo DoT peer is self-signed); no production surface was added to force it.
|
||||
- The metrics S3.3 "fixture pool" test drives a real `Pool` through the real `snapshot()` → `upstreams()` → render path with distinct per-field values.
|
||||
|
||||
## Addendum (2026-09-08): overlap test synchronizes on entry
|
||||
|
||||
The test `"overlapping exchanges each report the entry that answered that call"` failed once on a loaded CI runner and passed on rerun. It started two `io.concurrent` exchanges back to back and relied on the first entry's failing 50 ms stall to keep the first task in flight until the second task arrived. Nothing checked that the two calls were ever in flight together, and the schedule that produced two equal identities was not observed: every ordering traced by hand still ends the calls on different entries. The one thing known is that the test asserted an overlap it never proved.
|
||||
|
||||
The test now builds the schedule instead of timing it, and contains no sleep at all. Two new `Fake.Behavior` variants, `hold` and `hold_fail`, wait on a `std.Io.Semaphore` the test owns and then reply or fail; the wait propagates cancellation exactly as the existing `slow` sleep does. The first entry holds its first call on one gate and its second call on the other, and the second entry replies immediately. The test starts the first task, waits with `awaitInFlight(io, &first_entry, 1)`, starts the second task, waits with `awaitInFlight(io, &first_entry, 2)`, and only then posts the first gate. Reaching a count of two means both calls are inside the first entry at that moment, so `peak_in_flight == 2` holds by construction, and the first task's failover to the second entry provably runs while the second call is still held inside the first entry. Posting the second gate afterwards releases it. Every earlier assertion is kept, including the per-entry call counts and health counts. Each gate's deferred post is registered right after its task's deferred await, so it runs before that await and every early return (a failed wait, a skipped second task, a failed assertion) releases the held call instead of deadlocking. The fake draws its behaviour before it raises `in_flight`, so a count of one also fixes which call holds which gate; without that order the second call could draw the failing behaviour and the first await would wait on a gate posted only after it.
|
||||
|
||||
No pool bug was found, and the failing schedule was never observed. The original failure did not reproduce in 15 runs of the test binary pinned to two cores under six CPU hogs, with a diagnostic print on the assertion; that diagnostic was removed. `failover`'s attribution was not changed. After the change, 10 loaded two-core runs pass with the test reported `OK`, and each of those runs reports `1860 passed; 177 skipped; 0 failed.`. `zig build test` and `zig build test -Dintegration` both exit 0; the build runner prints no per-test summary of its own, only the known `failed command:` label described in AGENTS.md.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Milestone 39: Overview visual migration
|
||||
|
||||
Apply the decided visual language (`ui-visual-redesign.md`, 2026-09-03) to the real admin: tokens, shared chrome, the Overview page in full, and a first-pass sweep of the other pages. Two functional additions ride along by owner ruling: a client scope filter on Overview (server + UI), and the sidebar status block. One cross-cutting contract: all numeric formatting goes through one module with fixed rules.
|
||||
|
||||
Owner rulings (2026-09-03): eight named clients + Other (the existing `/api/overview` count — the prototype's nine is corrected to eight); the client filter ships in this milestone, not deferred; the status block ships in this milestone; this is a milestone, not an addendum; formatting must be consistent everywhere — "6d 04h" vs "06d 4h" vs a percentage shown "15.4%" when it is 15.44% are all defects.
|
||||
|
||||
## Formatting contract (applies to every session)
|
||||
|
||||
One module, `admin/src/lib/format.ts`, owns every user-visible number. No call site formats inline. Rules:
|
||||
|
||||
- **Counts**: thousands-grouped, `18,432`. Existing `Intl.NumberFormat` usage consolidates here.
|
||||
- **Percentages**: always two decimals, round half up, `15.44%`, `0.00%`, `100.00%`. Never one decimal, never trailing-zero trimming.
|
||||
- **Durations (uptime, ages)**: the two largest nonzero units, no zero padding, single space: `6d 4h`, `4h 12m`, `12m 5s`, `45s`. Below 1s: `<1s`.
|
||||
- **Rates**: one decimal, `12.8`.
|
||||
- **Timestamps**: unchanged — browser timezone display, per `ui-redesign.md` §Time scoping.
|
||||
|
||||
Unit tests pin each rule including the rounding. A sweep removes every ad-hoc `toFixed`/`toLocaleString`/hand-built duration in `admin/src`; the milestone is not done while one survives outside `format.ts`.
|
||||
|
||||
## Sessions
|
||||
|
||||
A (server: client scope) is independent. B (tokens + shared chrome + format module) first on the frontend; C (Overview) needs A and B; D (sweep) needs B and lands after C. Orchestrator writes the changelog.
|
||||
|
||||
---
|
||||
|
||||
## Session A: client scope on `/api/overview`
|
||||
|
||||
`GET /api/overview?period=…&client=<ip>` — optional `client`, validated as an address the same way the queries filters validate theirs. Semantics: every field of the response (totals, buckets, clients, other, types, routes, coverage) describes only that client's rows.
|
||||
|
||||
Engineering call, stated up front: the m36 projection tables aggregate across clients and cannot answer a scoped request for types/routes/totals. A scoped request therefore takes the raw-row scan path the 1h window already uses, over `idx_query_log_ts`, for every period. Household scale wears that cost; per-client projection tables would be overengineering. The web-layer response cache keys gain the client value.
|
||||
|
||||
Scoped response shape stays identical: `clients` carries at most that one client, `other` is present and all-zero. `coverage` is unchanged. OpenAPI, generated sample, TypeScript types, deterministic handler tests (scoped vs unscoped agreement on a seeded fixture: scoped totals sum to the client's slice; an unknown client returns zeros, not 404).
|
||||
|
||||
## Session B: tokens, format module, shared chrome
|
||||
|
||||
- `format.ts` per the contract above, with its tests, plus the removal sweep across `admin/src`.
|
||||
- `tokens.stylex.ts`: radius token 4px; `danger`/chart red split — chart red becomes `oklch(0.65 0.19 25)` (3:1 floor: non-text and ≥24px text only; `dangerText` keeps its darker value for small text); the eight client palette colors + Other gray `oklch(0.80 0.01 260)` as named tokens (values in `ui-visual-redesign.md`); card metrics.
|
||||
- Card chrome: every panel becomes a white hairline-bordered card, 4px radius, with a title (1.4rem, weight 650, ink) and a one-line muted description. Selector specificity note from the decision record applies.
|
||||
- Sidebar: MONITORING (Overview, Activity, Clients) / SYSTEM (Configuration, Diagnostics) group labels; foot status block reading the existing `/api/health` protection condition (Active/Paused dot + blocking state) and `/api/version` `uptime_seconds` (formatted per contract). Queries/min derives from the current Overview response (`totals.queries / window minutes`, rate format) and renders only where that response is live; other pages show the block without the rate row. Mobile drawer keeps the same content.
|
||||
- Toolbar selector styles (rectangular, radius token, 44px), ranked-list styles (full-bleed, red rule variant, no hover), progress-bar card style.
|
||||
|
||||
## Session C: Overview page
|
||||
|
||||
- Toolbar: client selector (from the clients list; "All devices" default) + period selector. Client is URL state beside period: `/overview?period=24h&client=…`, validated in the route search; the picker navigates.
|
||||
- Stat tiles restyled per the decision record (centered 2.5rem numerals, semantic color, lowercase captions). Percentage tile uses the two-decimal contract.
|
||||
- `chartKit.tsx` rework: smooth area chart (blue and red line+fill pairs, edge-to-edge x mapping, point hover with dots and guide line, side-flipping tooltip that never covers the hovered bucket, SVG `hidden` handled via attributes); stacked bars keep band hover, gain the 0.5-unit anti-seam bleed; palette tokens drive series colors, Other keys to the gray token; donut legend restyle.
|
||||
- Cache/progress presentation and the mobile ≤800px stack (top-bar shell, single column, merged stat card) per the decision record.
|
||||
- Tests: route search validation for `client`, chart geometry units where practical, axe pass, existing page tests updated.
|
||||
|
||||
## Session D: sweep
|
||||
|
||||
Activity, Clients, Configuration, Diagnostics adopt the tokens, card chrome, list styles and format module. No layout redesign of those pages in this milestone — chrome and formatting only. Byte budget check; screenshots of every page on the smoke server for owner acceptance before commit.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `/api/overview` scoped and unscoped agree on seeded fixtures; contract artifacts regenerated.
|
||||
- No numeric formatting outside `format.ts`; the pinned format tests pass.
|
||||
- Overview matches the decision record at 1440 and 420 widths (screenshot review).
|
||||
- All existing suites green; bundle within budget; no new dependency.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Milestone 40: Nix flake with tag-pinned hashes, one commit per cut
|
||||
|
||||
nxdns publishes a `flake.nix` whose package derivations fetch the release tarballs and carry their SRI hashes. A consumer pins `git+https://git.mial.net/mokhtar/nxdns.git?ref=refs/tags/vX.Y.Z` and Renovate bumps the tag. The hashes for a release are known before the release exists because the cut tool builds the same bytes locally that CI builds later, and CI verifies that equality before it uploads anything.
|
||||
|
||||
Owner rulings (2026-09-08): the consumer pins a tag, not a branch. A cut stays at two commits and two CI runs (the bump commit runs ci.yml; the tag runs release.yml). No follow-up commit, no extra run, no post-release hash edit. Design agreed with Codex (thread 2026-09-08) and its findings are folded in below.
|
||||
|
||||
## Why the hashes can be known in advance
|
||||
|
||||
The tarball bytes depend on: the Zig compiler version, the source tree at the bump commit, the admin bundle, the archive format, and nothing else. Today two things break that: the binary embeds the git commit sha (which the bump commit cannot know about itself) and the archive is produced by the runner's `tar` and `gzip`. This milestone removes both. Reproducibility is then a property the repo enforces on every CI run, not a hope.
|
||||
|
||||
## Session A: version identity without a commit sha
|
||||
|
||||
The release binary reports its version only. The git commit is gone from every surface; it is not replaced by a tag name or by a sentinel.
|
||||
|
||||
- `build.zig`: delete the `git-commit` option and the `git_commit` build option. Nothing else in `build.zig` changes in this session (Session B owns the rest of the file).
|
||||
- `src/version.zig` and `src/cli.zig` `runVersion`: print `nxdns {version}\nzig {zig_version}\n`.
|
||||
- `src/web/handlers/version.zig`: the `Body` loses `git_commit`. `admin/src/lib/types.ts`, `admin/src/lib/contractSamples.gen.ts` (regenerate with `zig build test -Dintegration -Dcontract-samples-out=<path>`, then copy), `admin/src/shell/AppShell.tsx` footer shows `nxdns v{version}`, and `AppShell.test.tsx` follow.
|
||||
- `tools/verify_dist.zig`: drop the `--git-commit` argument and the commit check; the `version` check stays.
|
||||
- `.gitea/workflows/gates.yml` package job and `.gitea/workflows/release.yml` publish job: drop `-Dgit-commit=...` from every `zig build dist` and `zig build verify-dist` line.
|
||||
- Docs: `docs/reference/cli.md`, `docs/how-to/verify-a-release.md`, `docs/how-to/upgrade.md`, `docs/how-to/install-with-docker.md` show the new `nxdns version` output. `verify-a-release.md` replaces the commit-matching step with the reproduction check from Session D (rebuild at the tag with the same toolchain, compare `SHA256SUMS` and `flake.nix`).
|
||||
- Tests: every test that asserted the commit in the CLI output, the API body, or the footer asserts the new shape.
|
||||
|
||||
## Session B: Zig-owned archive and pin check
|
||||
|
||||
`tools/dist_stage.zig` gains two modes and `build.zig` stops calling system `tar` and `gzip`.
|
||||
|
||||
- `archive --root <stage dir> --payload <dir name> --out <file.tar.gz>`: walks the payload directory, sorts entries by full path (bytes), writes a GNU tar stream with `std.tar.Writer` (`writeDir` for the payload directory, `writeFile` for regular files, mode 0755 for the directory and the `nxdns` binary, 0644 for every other file, mtime 0, uid 0, gid 0, empty user and group names), finishes with `finishPedantically`, and compresses with `std.compress.flate.Compress` in the `.gzip` container at a fixed level. The output bytes must not depend on the absolute path of the stage directory, the umask, the clock, the locale, or the host's tar/gzip.
|
||||
- `pin-check --sums <SHA256SUMS> --flake <flake.nix> --version <x.y.z>`: parses the generated block in `flake.nix` (see Session C), converts each `sha256` SRI value to hex, and fails with a per-target message when the tarball entry in `SHA256SUMS` differs or the block's version differs. Only tarball entries are compared.
|
||||
- `pin --sums <SHA256SUMS> --flake <flake.nix> --version <x.y.z>`: rewrites the generated block from the sums file. Used by the cut tool (Session D).
|
||||
- `build.zig`: the cross-targets loop replaces `tar_run` and `gzip_run` with one `dist_stage archive` run. A separate `verify-pins` step runs `dist_stage pin-check` against `flake.nix` and `zig-out/dist/SHA256SUMS`. It is not part of `verify-dist`: an ordinary commit between two cuts builds the `build.zig.zon` version from a tree that differs from the released one, so its bytes never match the pins and must not be checked against them. Correction (2026-09-08): Session B first put the check inside `verify-dist`; Session D moves it out.
|
||||
- Reproducibility test (`zig build test`): stage the same fixture tree under two different absolute directories with different mtimes and file order on disk, archive both, assert byte equality, then read the archive back with `std.tar` and assert names, modes, and sizes.
|
||||
- `.gitea/workflows/gates.yml`: add `NPM_VERSION` to the top-level `env` next to `NODE_VERSION`; the frontend job asserts `node --version` and `npm --version` equal the pinned values before `npm ci`. The frontend job runs `npm run build` with `umask 022`, `LC_ALL=C`, `LANG=C`, `TZ=UTC`, `SOURCE_DATE_EPOCH=0`. `admin/vite.config.ts` reads no other environment (verified: only `VITEST`).
|
||||
- `docs/how-to/verify-a-release.md` describes the archive layout (fixed modes, zero timestamps, gzip without a name or mtime) so a reader can reproduce it.
|
||||
|
||||
Deviation (Session B): the reproducibility test stages its two trees under two `std.testing.tmpDir` directories rather than two absolute paths. Zig 0.16.0's std exposes no way to read the current working directory, so an absolute path would need a raw `getcwd` syscall. The two roots still differ in path text, file creation order and mtimes, which is what the assertion is about.
|
||||
|
||||
## Session C: flake.nix
|
||||
|
||||
- `flake.nix` at the repo root with one input, `nixpkgs`, and `packages.{aarch64-linux,x86_64-linux}.default` built from the derivation that `rpi.mial.net` carries in `nixos/pkgs/nxdns.nix` today: `fetchurl` of `https://git.mial.net/mokhtar/nxdns/releases/download/v${version}/nxdns-${version}-${triple}.tar.gz`, `dontPatchELF`, `dontStrip`, install `nxdns`, `LICENSE`, `THIRD-PARTY-NOTICES`, and an `installCheckPhase` that asserts no `INTERP` and no `NEEDED` with `readelf` and that `nxdns version` output starts with `nxdns ${version}`. `meta.license = eupl12`, `meta.mainProgram = "nxdns"`.
|
||||
- The version and the two hashes live in one delimited block that Session B's `pin` mode rewrites and that nothing else edits by hand:
|
||||
|
||||
```nix
|
||||
# BEGIN GENERATED BY zig build cut
|
||||
version = "0.0.16";
|
||||
hashes = {
|
||||
"aarch64-linux" = "sha256-...";
|
||||
"x86_64-linux" = "sha256-...";
|
||||
};
|
||||
# END GENERATED BY zig build cut
|
||||
```
|
||||
|
||||
The tool finds the two delimiter lines by their text after leading whitespace, keeps the begin line's indentation, and writes the six inner lines at that indentation (the hash lines two spaces deeper). A file with zero or two begin lines is an error.
|
||||
|
||||
- `flake.lock` is committed. The consumer sets `inputs.nxdns.inputs.nixpkgs.follows = "nixpkgs"`.
|
||||
- `nix flake check --no-build` passes locally against the committed file. Evaluation does not fetch the tarball.
|
||||
- `docs/how-to/install-with-nix.md` documents the consumer side: the flake input with a tag ref, the `follows` line, and the Renovate `nix` manager. It states the window between the tag push and the asset upload during which a fresh pin fetches a 404, and that Renovate only proposes tags that already exist.
|
||||
- `flake.nix` is not a build input of the tarballs; the pins do not feed back into the bytes they describe.
|
||||
|
||||
## Session D: the cut tool pins before it commits
|
||||
|
||||
`tools/cut.zig` gains a `pin` stage between the changelog gates and the bump commit. The bump commit's exact diff becomes `build.zig.zon` plus `flake.nix`.
|
||||
|
||||
1. Toolchain parity: read `NODE_VERSION`, `NPM_VERSION`, and `ZIG_VERSION` from `.gitea/workflows/gates.yml` and fail unless `node --version`, `npm --version`, and `zig version` match exactly, and unless the host is x86_64 Linux with glibc (host-native bundler bindings, shipped per platform and libc). The check runs before the manifest write so a refusal leaves a clean tree.
|
||||
2. Admin bundle: in `admin/`, run `npm ci` (npm replaces `node_modules` itself) then `npm run build` with the same normalized environment as the CI frontend job (`umask 022`, `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`, `PATH` and `HOME` passed through, nothing else). Fail on a dirty `admin/dist` that the build did not produce.
|
||||
3. Dist: run `zig build dist -Dversion-string=<x.y.z> -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe` with the same normalized environment and a private `--cache-dir` under the scratch directory, so a stale local cache cannot leak into the bytes. The global cache stays shared: it is content-addressed, and a fresh one refetches every dependency and trips on zig 0.16.0's unzip, which expects `<global>/tmp` to exist.
|
||||
4. Pin: run `dist_stage pin` on `zig-out/dist/SHA256SUMS` and `flake.nix`, then `zig build verify-dist` and `zig build verify-pins` with the same flags, then `nix flake check --no-build`.
|
||||
5. Commit: the existing bump commit now includes `flake.nix`; the clean-tree gate accepts `admin/dist` and `zig-out` as ignored paths only.
|
||||
|
||||
Where CI runs the pin check:
|
||||
|
||||
- `.gitea/workflows/gates.yml` package job: after `verify-dist` and after the release payload is uploaded (so a mismatch still leaves the bytes downloadable), run `zig build verify-pins` when the pushed commit changed the declared `.version` against its first parent or changed `flake.nix` against it (the checkout needs depth 2). That covers the bump commit and a re-pin during a cut, the commits whose pins are otherwise unverified before the tag. Every other commit skips the step, and the skip is printed, not silent.
|
||||
- `.gitea/workflows/release.yml` publish job: after `verify-dist` and before the image push, the draft, and every upload, run `zig build verify-pins` unconditionally. The tag's tree is the bump commit's tree, and the flake block's version must equal the tag.
|
||||
|
||||
The remaining stages (push, ci.yml wait, tag, release.yml wait) are unchanged. CI rebuilds the same bytes from the same inputs; `verify-pins` in the package job fails the run before the tag if the bytes differ, and the same step in the publish job fails before any image push, draft, or upload. `specs/release-cut.md` gains the pin stage and the parity requirement.
|
||||
|
||||
Cut tool tests: the `.gitea` version parser, the environment builder (asserts the allowed keys and nothing else), and the pin stage on a fixture repo.
|
||||
|
||||
Deviation (Session D): the pin stage is tested in pieces rather than on a fixture repository. There is no fixture-repo helper in `tools/cut.zig` to extend, and the stage's remaining content is four process spawns whose fixture would have to run `npm ci` and two cross-compiled release builds inside `zig build test`. What is tested instead is the parser (against a fixture and against the real `gates.yml`), the environment builder, and the `sh` wrapper — asserting it applies the umask, keeps the real command as the direct child, and reports its exit code. The spawns themselves follow the file's existing rule that process plumbing lives behind thin call sites and is not mocked. Two facts the spec left open: `umask(2)` is not in zig 0.16.0's `std.posix` (only `std.c.umask`, which these tools do not link), so the wrapper is the mechanism; and `build.zig` had no `pin-flake` step, so Session D added one beside `verify-pins`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Signing the flake outputs, a Hydra or cachix binary cache, Darwin packages, and a NixOS module. Renovate tag discovery on Gitea is a consumer-side acceptance test run once on `rpi.mial.net` after the first tagged release with a flake.
|
||||
|
||||
Result of that test (2026-09-09, v0.0.18, Renovate 42.99.0): no pull request. Renovate's `nix` manager advances `flake.lock` along the ref an input already tracks and never moves a `refs/tags/vX.Y.Z` ref; it returned an empty release list for every flake input, GitHub-hosted ones included. The flake itself is correct and v0.0.17 deployed through it. Tag bumps need a regex custom manager with the `gitea-tags` datasource plus a lock refresh in the same pull request, which the consumer owns; `docs/how-to/install-with-nix.md` section 4 now says so instead of claiming the `nix` manager does it. With that manager in place, Renovate opened rpi.mial.net PR 5 the same day, bumping the input to v0.0.18 and refreshing `flake.lock`: the acceptance test passes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `zig build test` green, including the two-path reproducibility test.
|
||||
- `nxdns version` prints two lines and no commit; `/api/version` has no `git_commit`; the admin footer shows `nxdns v<version>`.
|
||||
- `zig build dist` produces tarballs byte-identical across two clean checkouts on the same toolchain; `verify-pins` fails when `flake.nix` disagrees with `SHA256SUMS`; `verify-dist` passes on any tree.
|
||||
- `nix flake check --no-build` passes.
|
||||
- `zig build cut -- patch` on a fixture reaches the bump commit with `build.zig.zon` and `flake.nix` as its only diff.
|
||||
- CHANGELOG `## [Unreleased]` records the flake, the archive change, and the removal of the commit sha from the version surfaces.
|
||||
@@ -1377,3 +1377,26 @@ Deviations from the text above, recorded after the first six sessions verified.
|
||||
**S9 integration + fuzz.** Case 3 splits into the two real failure modes: a checksum-mismatched file marks the source `.load_failed` and the reload succeeds without it (generation advances); only a checksum-clean but malformed body fails `reload` and leaves the previous snapshot serving. Case 7 proves the cap from the response head: an explicit `content-length` of 100 MiB returns `error.BodyTooLarge` before any body streams. Case 8 back-dates `last_updated` through `updateSourceStats` and asserts inode and mtime of the compiled file are unchanged. Case 15 asserts `budget/2 <= elapsed < 2*budget` (the POSIX backend wakes ~0.8 ms early on a 200 ms deadline). The fuzz corpus is inline — `tests/fuzz/corpus.zig` imports the `dns` module, which the blocklist-fuzz module does not have. Fuzz targets assert properties, not only absence of crashes: `Line.text` windows the input (pointer containment), `covers_apex` only on `.wildcard`, `detectFormat`'s answer survives `parseLine` over the same bytes, and `matches` is exercised on rejected patterns.
|
||||
|
||||
**Final wiring.** `build.zig` gained the `blocklist-fuzz` artifact (module import `parsers` → `src/filter/parsers.zig`, LLVM backend under `-Dfuzz`), hung off `test_step` beside the dns fuzz artifact. Evaluation: `zig build test`, `zig build test -Dintegration` and `zig build cross` all exit 0; both cross executables are statically linked.
|
||||
|
||||
## Addendum (2026-09-12): a download failure names its cause
|
||||
|
||||
Observed on the Pi (nxdns 0.0.17): four of eight sources fail on every refresh pass since 2026-09-11, the same four each time, and the log line for each is `download failed: TlsFailed` one pass and `download failed: ReceiveFailed` the next. The same binary loads all eight from another machine. The line cannot tell a TLS alert from a reset connection from a truncated chunk, because `fetcher.mapError` collapses the cause into the six-member taxonomy and `reportDownloadFailure` logs only the taxonomy name. The Diagnostics event carries the same text. A reader of either has nothing to act on. `upstream/doh_client.zig` already unwraps the concrete cause that `std.http.Client` stashes on the connection (`sendCause`, `headCause`, `bodyCause`, `readCause`); the fetcher does not.
|
||||
|
||||
Change:
|
||||
|
||||
- `fetcher.zig`: `Fetcher` gains `last_failure: ?Failure`, cleared at the top of `fetch` beside `last_status`, set on every error return. `Failure` is `{ phase: Phase, cause: anyerror, status: ?std.http.Status, bytes_read: u64 }`. `Phase` gains `receive_body`, distinct from the head. `cause` is the unwrapped concrete error: the three unwrap helpers move from `doh_client.zig` into `upstream/transport.zig` (or a sibling file both import; the coder picks the smaller diff), keep their tests, and both clients call them. The taxonomy `Error` and `mapError` are unchanged; a caller that ignores `last_failure` sees exactly what it saw before. `bytes_read` counts body bytes delivered to the writer before the failure, so a body that dies at byte 0 and one that dies at 5 MB read differently.
|
||||
- `manager.zig`: `fetchWithin` measures elapsed milliseconds on the awake clock. `reportDownloadFailure` takes the failure and elapsed time and logs one line: `blocklist <label>: download failed: <taxonomy> (<phase>, cause <CauseName>, http <status or none>, <bytes> bytes, <ms> ms)`. On the `Timeout` outcome of the expiry race the losing fetch is cancelled before the line is built, and it records itself on the way out, so the line names the phase it was in, `cause Canceled`, the status if a head arrived, and the bytes delivered so far: `Timeout (receive_body, cause Canceled, http 200, 5242880 bytes, 300000 ms)`. `download` clears `last_failure` and `last_status` before anything else runs, so a null record means no fetch reached the network for this source (a local file failure, or no concurrency to spawn the race); that prints as `<taxonomy> (no fetch, http none, - bytes, <ms> ms)`. Elapsed is captured when the race resolves, before the loser is cancelled, so a timeout's milliseconds are the time the transfer was given. `SourceStatus.fail` receives the same compact text, so the Diagnostics event detail shows it; `max_error_len` rises from 128 to 192 so the longest cause name and the numbers fit without truncation (check the longest `std.crypto.tls.Client.ReadError` and `std.http.Client` error names; `events.Store.max_detail_len` is 512 and is not touched).
|
||||
- Bound: this changes the content of an existing warning line, not its frequency. One line per failed source per pass, at most `sources × passes` per day; a pass runs once per `blocklist_update.interval_hours` or on a manual update. No per-chunk or per-retry logging is added. Nothing new is written at info or debug level.
|
||||
- Tests: `mapError` tests unchanged; a test that a head failure with a stashed `ConnectionResetByPeer` yields `last_failure.cause == error.ConnectionResetByPeer` and phase `receive` (the doh tests' stub connection pattern); a test that a body failure records `bytes_read` and `receive_body`; a `reportDownloadFailure` format test with the fixed text `blocklist source 3 'ads' 'https://lists.example': download failed: ReceiveFailed (receive_body, cause HttpChunkTruncated, http 200, 1048576 bytes, 812 ms)`; the existing secrecy assertions on the label stay.
|
||||
|
||||
Out of scope: a TLS handshake failure stays `TlsInitializationFailed`, because `std.http.Client` collapses every handshake fault at `Client.zig:1470` before this code sees it; distinguishing an alert from a certificate failure would mean driving `std.crypto.tls.Client` by hand. Retries and a per-source backoff are not added.
|
||||
|
||||
## Addendum (2026-09-12): a blocklist download never reuses a pooled connection
|
||||
|
||||
The line above found the cause the same day nxdns 0.0.19 reached the Pi. A manual refresh at 14:28 failed the same four sources with one record each: `ReceiveFailed (receive, cause HttpConnectionClosing, http none, 0 bytes, 0 ms)`. Under one millisecond of elapsed time, and no response head byte after the request was sent, means the peer had already closed the socket. `std.http.Client` returns `HttpConnectionClosing` when a reused connection hits EOF before the first head byte (`std/http.zig:400`), and `std/http/test.zig:1331` pins that a client never retries a stale pooled connection. The startup pass at 13:03 had fetched `big.oisd.nl` successfully and the client parked that keep-alive connection in `fetch_http`'s pool; the server closed it long before 14:28, and the next request to the same host picked it up. The same shape explains every earlier observation: the first fetch to a host in a process succeeds, a later fetch to that host fails, and the daily pass reuses connections a day old. The `TlsFailed` variant of the older lines is the same dead socket failing inside the TLS layer instead of at the head.
|
||||
|
||||
Change: `fetcher.fetch` requests with `.keep_alive = false`, and `fetch_http` in app.zig is created with `connection_pool.free_size = 0`, so `release` destroys every connection regardless of the request flag (`Client.zig:133`), including the one a failed send leaves in the `.ready` state that `Request.deinit` would otherwise pool, and no connection is ever taken from that pool. The flag alone is not enough: `Client.request` takes a pooled connection before it stores the flag (`Client.zig:1725`, `:1742`). The request then carries `connection: close`, the client marks the connection closing at the head, and `release` destroys it instead of pooling it (`Client.zig:133`, `:1167`). A blocklist download is one bulk transfer per source per day; there is nothing to gain from a pooled connection and a stale one costs a source for a day. `fetch_http` stays a separate client from `dns_http`, so the DoH pool is untouched.
|
||||
|
||||
Tests: an integration test against `HttpFixture` where the server closes the socket after each response: two consecutive fetches to the same URL both succeed. Without the change the second fails with `HttpConnectionClosing`, which is the exact record the Pi produced. A unit test that the request header the fetcher sends carries `connection: close` if the fixture route is the cheaper path.
|
||||
|
||||
Out of scope: a retry on `HttpConnectionClosing` for the DoH client, which keeps pooling by design and has its own health logic.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user