Compare commits
4
Commits
5b3d1cd65c
...
v0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c7da2381
|
||
|
|
794ea6541f
|
||
|
|
ba037c5958
|
||
|
|
1e97c80f6b
|
+22
-22
@@ -119,39 +119,39 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: admin/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web
|
working-directory: admin
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
working-directory: web
|
working-directory: admin
|
||||||
run: npm run format:check
|
run: npm run format:check
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
working-directory: web
|
working-directory: admin
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
working-directory: web
|
working-directory: admin
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: web
|
working-directory: admin
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
working-directory: web
|
working-directory: admin
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
# The licence inventory has to cover every package whose bytes ship, and
|
# The licence inventory has to cover every package whose bytes ship, and
|
||||||
# the lockfile does not answer that question: it lists what could be
|
# the lockfile does not answer that question: it lists what could be
|
||||||
# reached, not what rollup kept. The bundle is what this reads. The logic
|
# reached, not what rollup kept. The bundle is what this reads. The logic
|
||||||
# lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop
|
# lives in admin/scripts/, unit-tested by `npm test`, so it runs on a laptop
|
||||||
# exactly as it runs here (milestone-14 deviation 24).
|
# exactly as it runs here (milestone-14 deviation 24).
|
||||||
- name: Assert the packages bundled into web/dist are the recorded ones
|
- name: Assert the packages bundled into admin/dist are the recorded ones
|
||||||
working-directory: web
|
working-directory: admin
|
||||||
run: npm run assert-bundled
|
run: npm run assert-bundled
|
||||||
|
|
||||||
# The package and container jobs consume this bundle instead of building
|
# The package and container jobs consume this bundle instead of building
|
||||||
@@ -164,16 +164,16 @@ jobs:
|
|||||||
#
|
#
|
||||||
# A later move to v4 has to add `include-hidden-files: true` here.
|
# A later move to v4 has to add `include-hidden-files: true` here.
|
||||||
# `npm run build` writes the freshness stamp to the hidden file
|
# `npm run build` writes the freshness stamp to the hidden file
|
||||||
# web/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop
|
# admin/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop
|
||||||
# dotfiles by default. It is inactive today — v3 keeps them, and the
|
# dotfiles by default. It is inactive today — v3 keeps them, and the
|
||||||
# package job's target path skips the stamp check regardless — but a move
|
# package job's target path skips the stamp check regardless — but a move
|
||||||
# to v4 that also pointed the download back at web/dist would fail with
|
# to v4 that also pointed the download back at admin/dist would fail with
|
||||||
# "web/dist is stale".
|
# "admin/dist is stale".
|
||||||
- name: Upload the built web UI
|
- name: Upload the built web UI
|
||||||
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
|
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
|
||||||
with:
|
with:
|
||||||
name: web-dist
|
name: admin-dist
|
||||||
path: web/dist
|
path: admin/dist
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
package:
|
package:
|
||||||
@@ -201,22 +201,22 @@ jobs:
|
|||||||
- name: Create the fetch temp dir zig assumes
|
- name: Create the fetch temp dir zig assumes
|
||||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||||
|
|
||||||
# `dist` refuses web/dist-placeholder (ruling 4), so a real bundle has to
|
# `dist` refuses admin/dist-placeholder (ruling 4), so a real bundle has to
|
||||||
# exist before the packaging gate runs. It arrives from the frontend job,
|
# exist before the packaging gate runs. It arrives from the frontend job,
|
||||||
# already formatted, linted, typechecked, tested and licence-checked.
|
# already formatted, linted, typechecked, tested and licence-checked.
|
||||||
#
|
#
|
||||||
# The target is deliberately not `web/dist`: build.zig runs the freshness
|
# The target is deliberately not `admin/dist`: build.zig runs the freshness
|
||||||
# stamp check for that exact path and no other (milestone-15 ruling 5),
|
# stamp check for that exact path and no other (milestone-15 ruling 5),
|
||||||
# and the check shells out to `node`. Here it would buy nothing — the
|
# and the check shells out to `node`. Here it would buy nothing — the
|
||||||
# stamp hashes the web/ sources, not the bundle, so against a checkout of
|
# stamp hashes the admin/ sources, not the bundle, so against a checkout of
|
||||||
# the same commit that built the bundle it can only agree. An explicit
|
# the same commit that built the bundle it can only agree. An explicit
|
||||||
# path is the case build.zig documents for a bundle built elsewhere, and
|
# path is the case build.zig documents for a bundle built elsewhere, and
|
||||||
# taking it keeps node out of this job entirely.
|
# taking it keeps node out of this job entirely.
|
||||||
- name: Download the web UI built by the frontend job
|
- name: Download the web UI built by the frontend job
|
||||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||||
with:
|
with:
|
||||||
name: web-dist
|
name: admin-dist
|
||||||
path: web-dist-ci
|
path: admin-dist-ci
|
||||||
|
|
||||||
# Ruling 2: build.zig.zon is the only place besides the tag that carries
|
# Ruling 2: build.zig.zon is the only place besides the tag that carries
|
||||||
# the version, and ruling 5 makes verify-dist assert the two agree. The
|
# the version, and ruling 5 makes verify-dist assert the two agree. The
|
||||||
@@ -239,7 +239,7 @@ jobs:
|
|||||||
zig build dist \
|
zig build dist \
|
||||||
-Dversion-string="$CI_VERSION" \
|
-Dversion-string="$CI_VERSION" \
|
||||||
-Dgit-commit="$GITHUB_SHA" \
|
-Dgit-commit="$GITHUB_SHA" \
|
||||||
-Dweb-dist=web-dist-ci \
|
-Dadmin-dist=admin-dist-ci \
|
||||||
-Doptimize=ReleaseSafe
|
-Doptimize=ReleaseSafe
|
||||||
|
|
||||||
# verify-dist owns every assert the CI shell used to make: ELF static
|
# verify-dist owns every assert the CI shell used to make: ELF static
|
||||||
@@ -255,7 +255,7 @@ jobs:
|
|||||||
zig build verify-dist \
|
zig build verify-dist \
|
||||||
-Dversion-string="$CI_VERSION" \
|
-Dversion-string="$CI_VERSION" \
|
||||||
-Dgit-commit="$GITHUB_SHA" \
|
-Dgit-commit="$GITHUB_SHA" \
|
||||||
-Dweb-dist=web-dist-ci \
|
-Dadmin-dist=admin-dist-ci \
|
||||||
-Doptimize=ReleaseSafe
|
-Doptimize=ReleaseSafe
|
||||||
|
|
||||||
# deploy/docker/Dockerfile copies both of these trees and nothing else
|
# deploy/docker/Dockerfile copies both of these trees and nothing else
|
||||||
|
|||||||
@@ -269,10 +269,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: admin/package-lock.json
|
||||||
|
|
||||||
- name: Build the web UI
|
- name: Build the web UI
|
||||||
working-directory: web
|
working-directory: admin
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
@@ -283,7 +283,7 @@ jobs:
|
|||||||
zig build dist
|
zig build dist
|
||||||
-Dversion-string="$VERSION"
|
-Dversion-string="$VERSION"
|
||||||
-Dgit-commit="$TAG_COMMIT"
|
-Dgit-commit="$TAG_COMMIT"
|
||||||
-Dweb-dist=web/dist
|
-Dadmin-dist=admin/dist
|
||||||
-Doptimize=ReleaseSafe
|
-Doptimize=ReleaseSafe
|
||||||
|
|
||||||
- name: Verify the release artifacts
|
- name: Verify the release artifacts
|
||||||
@@ -291,7 +291,7 @@ jobs:
|
|||||||
zig build verify-dist
|
zig build verify-dist
|
||||||
-Dversion-string="$VERSION"
|
-Dversion-string="$VERSION"
|
||||||
-Dgit-commit="$TAG_COMMIT"
|
-Dgit-commit="$TAG_COMMIT"
|
||||||
-Dweb-dist=web/dist
|
-Dadmin-dist=admin/dist
|
||||||
-Doptimize=ReleaseSafe
|
-Doptimize=ReleaseSafe
|
||||||
|
|
||||||
# Step 9. Extracted and validated before anything is pushed anywhere, so
|
# Step 9. Extracted and validated before anything is pushed anywhere, so
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
.zig-cache/
|
.zig-cache/
|
||||||
zig-out/
|
zig-out/
|
||||||
zig-pkg/
|
zig-pkg/
|
||||||
web/node_modules/
|
admin/node_modules/
|
||||||
web/dist/
|
admin/dist/
|
||||||
web/dist-sourcemap/
|
admin/dist-sourcemap/
|
||||||
web-dist-ci/
|
admin-dist-ci/
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ One trap: running a cached test binary by hand with `--listen=-` aborts with `in
|
|||||||
|
|
||||||
## Regenerating the contract samples
|
## Regenerating the contract samples
|
||||||
|
|
||||||
`web/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
|
`admin/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
|
||||||
|
|
||||||
```
|
```
|
||||||
zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts"
|
zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
|
||||||
```
|
```
|
||||||
|
|
||||||
then update `web/src/lib/types.ts` to match and commit both. Never edit the generated file by hand.
|
then update `admin/src/lib/types.ts` to match and commit both. Never edit the generated file by hand.
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ Sections are written by hand. Nothing here is generated from commit messages: th
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.0.4] - 2026-08-16
|
||||||
|
|
||||||
|
The names learned in 0.0.3 now show up where queries do: the live page and the query log name each client instead of printing its address.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Client names in the query tables.** The live page and the query log show each query's client by name, with the same precedence as the clients page: a hand-typed name wins, else the learned name (muted, tagged *learned*), else the bare address. When a name replaces the address, the address stays readable as the row's tooltip. Devices that appear mid-stream show their address first and pick up their name within half a minute.
|
||||||
|
|
||||||
## [0.0.3] - 2026-08-15
|
## [0.0.3] - 2026-08-15
|
||||||
|
|
||||||
Devices name themselves: the clients table asks the router over reverse DNS instead of waiting for the operator to type every name. The CI container gate also moved from workflow shell into a compiled, tested tool, which fixed a latent temp-directory bug shared with the release tool.
|
Devices name themselves: the clients table asks the router over reverse DNS instead of waiting for the operator to type every name. The CI container gate also moved from workflow shell into a compiled, tested tool, which fixed a latent temp-directory bug shared with the release tool.
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ src/
|
|||||||
rules.zig local.zig lookup.zig pause.zig settings.zig
|
rules.zig local.zig lookup.zig pause.zig settings.zig
|
||||||
upstream_health.zig certs.zig health.zig version.zig
|
upstream_health.zig certs.zig health.zig version.zig
|
||||||
|
|
||||||
web/ # Vite + React + TS + StyleX + React Aria + TanStack
|
admin/ # Vite + React + TS + StyleX + React Aria + TanStack
|
||||||
vendor/ # sqlite3 amalgamation, mbedtls (pinned)
|
vendor/ # sqlite3 amalgamation, mbedtls (pinned)
|
||||||
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
|
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
|
||||||
tests/ # dns/ integration/ fuzz/
|
tests/ # dns/ integration/ fuzz/
|
||||||
|
|||||||
@@ -60,20 +60,20 @@ Full install instructions, including the systemd path and the Pi 5 recipe, are i
|
|||||||
Requires [Zig 0.16.0](https://ziglang.org/download/) and Node.js 24 (for the web UI). C dependencies (SQLite, mbedTLS) are vendored and built by `zig build`.
|
Requires [Zig 0.16.0](https://ziglang.org/download/) and Node.js 24 (for the web UI). C dependencies (SQLite, mbedTLS) are vendored and built by `zig build`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
(cd web && npm ci && npm run build) # web UI -> web/dist
|
(cd admin && npm ci && npm run build) # web UI -> admin/dist
|
||||||
zig build -Dweb-dist=web/dist # native binary -> zig-out/bin/nxdns
|
zig build -Dadmin-dist=admin/dist # native binary -> zig-out/bin/nxdns
|
||||||
zig build test --summary all # unit tests
|
zig build test --summary all # unit tests
|
||||||
```
|
```
|
||||||
|
|
||||||
The release artifacts come out of the same build graph, so the whole release build runs on a laptop exactly as it runs on the CI runner:
|
The release artifacts come out of the same build graph, so the whole release build runs on a laptop exactly as it runs on the CI runner:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
(cd web && npm ci && npm run build) # required: dist refuses the placeholder
|
(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)
|
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" -Dgit-commit=$(git rev-parse HEAD) \
|
||||||
-Dweb-dist=web/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/
|
-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" -Dgit-commit=$(git rev-parse HEAD) \
|
||||||
-Dweb-dist=web/dist -Doptimize=ReleaseSafe # the release checks
|
-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.
|
The version comes from `build.zig.zon` because `verify-dist` asserts the two agree; a tag sets both.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 303 B After Width: | Height: | Size: 303 B |
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "nxdns-web",
|
"name": "nxdns-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nxdns-web",
|
"name": "nxdns-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stylexjs/stylex": "0.19.0",
|
"@stylexjs/stylex": "0.19.0",
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "nxdns-web",
|
"name": "nxdns-admin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
Before Width: | Height: | Size: 262 B After Width: | Height: | Size: 262 B |
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// The set of npm packages whose bytes reach web/dist must be exactly the set
|
// The set of npm packages whose bytes reach admin/dist must be exactly the set
|
||||||
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
|
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
|
||||||
//
|
//
|
||||||
// The shipped build carries no sourcemaps, so this makes a second build with
|
// The shipped build carries no sourcemaps, so this makes a second build with
|
||||||
// them into its own directory: the `sources` list of each chunk names the
|
// them into its own directory: the `sources` list of each chunk names the
|
||||||
// modules that went into it, and the artifact `npm run build` produced stays
|
// modules that went into it, and the artifact `npm run build` produced stays
|
||||||
// untouched. Runs from web/ as `npm run assert-bundled`, on a laptop exactly as
|
// untouched. Runs from admin/ as `npm run assert-bundled`, on a laptop exactly as
|
||||||
// on the runner.
|
// on the runner.
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
@@ -85,10 +85,10 @@ try {
|
|||||||
|
|
||||||
const recorded = recordedPackages(identity);
|
const recorded = recordedPackages(identity);
|
||||||
if (recorded === null) {
|
if (recorded === null) {
|
||||||
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section");
|
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into admin/dist]' section");
|
||||||
}
|
}
|
||||||
if (recorded.length === 0) {
|
if (recorded.length === 0) {
|
||||||
fail("assert-bundled: the '[npm packages bundled into web/dist]' section is empty");
|
fail("assert-bundled: the '[npm packages bundled into admin/dist]' section is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { added, removed } = comparePackages(recorded, bundled);
|
const { added, removed } = comparePackages(recorded, bundled);
|
||||||
@@ -96,12 +96,12 @@ if (added.length !== 0 || removed.length !== 0) {
|
|||||||
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
|
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
|
||||||
fail(
|
fail(
|
||||||
[
|
[
|
||||||
"the set of npm packages in web/dist has changed (-recorded +current).",
|
"the set of npm packages in admin/dist has changed (-recorded +current).",
|
||||||
"Work out what the change means for licenses/inventory.zon first, then record",
|
"Work out what the change means for licenses/inventory.zon first, then record",
|
||||||
"the new list in that section of licenses/dependency-identity.txt.",
|
"the new list in that section of licenses/dependency-identity.txt.",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdout.write(`web/dist bundles exactly the ${bundled.length} recorded packages:\n`);
|
process.stdout.write(`admin/dist bundles exactly the ${bundled.length} recorded packages:\n`);
|
||||||
for (const name of bundled) process.stdout.write(`${name}\n`);
|
for (const name of bundled) process.stdout.write(`${name}\n`);
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
// brace inside a string blinds the stripper; and with several stylesheets it
|
// brace inside a string blinds the stripper; and with several stylesheets it
|
||||||
// judges each alone, not their load order in the document.
|
// judges each alone, not their load order in the document.
|
||||||
//
|
//
|
||||||
// This check runs from web/ as part of `npm run build`.
|
// This check runs from admin/ as part of `npm run build`.
|
||||||
|
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
@@ -110,7 +110,7 @@ for (const sheet of sheets) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
console.error("Wrap it in a layer declared before StyleX's, as web/src/styles.css does.");
|
console.error("Wrap it in a layer declared before StyleX's, as admin/src/styles.css does.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
// lockfile, no version and no dependency set changes — only the bundle does. So
|
// lockfile, no version and no dependency set changes — only the bundle does. So
|
||||||
// the bundle is what this reads.
|
// the bundle is what this reads.
|
||||||
|
|
||||||
const sectionHeading = "[npm packages bundled into web/dist]";
|
const sectionHeading = "[npm packages bundled into admin/dist]";
|
||||||
|
|
||||||
// A sourcemap `sources` entry for a dependency ends in
|
// A sourcemap `sources` entry for a dependency ends in
|
||||||
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
|
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
|
||||||
@@ -48,7 +48,7 @@ describe("recordedPackages", () => {
|
|||||||
"[some earlier section]",
|
"[some earlier section]",
|
||||||
"ignored",
|
"ignored",
|
||||||
"",
|
"",
|
||||||
"[npm packages bundled into web/dist]",
|
"[npm packages bundled into admin/dist]",
|
||||||
"react",
|
"react",
|
||||||
"@tanstack/react-query",
|
"@tanstack/react-query",
|
||||||
"",
|
"",
|
||||||
@@ -64,7 +64,7 @@ describe("recordedPackages", () => {
|
|||||||
|
|
||||||
it("distinguishes a missing section from an empty one", () => {
|
it("distinguishes a missing section from an empty one", () => {
|
||||||
expect(recordedPackages("[other]\nx\n")).toBeNull();
|
expect(recordedPackages("[other]\nx\n")).toBeNull();
|
||||||
expect(recordedPackages("[npm packages bundled into web/dist]\n\n[next]\n")).toEqual([]);
|
expect(recordedPackages("[npm packages bundled into admin/dist]\n\n[next]\n")).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Freshness stamp for web/dist (milestone-15 ruling 5). A stale dist has
|
// Freshness stamp for admin/dist (milestone-15 ruling 5). A stale dist has
|
||||||
// already shipped a crashing settings page once. Write mode runs from web/ as
|
// already shipped a crashing settings page once. Write mode runs from admin/ as
|
||||||
// part of `npm run build`; check mode runs from the repository root as a
|
// part of `npm run build`; check mode runs from the repository root as a
|
||||||
// build.zig system command. Every path resolves from this file's own location
|
// build.zig system command. Every path resolves from this file's own location
|
||||||
// so both working directories hash the same set.
|
// so both working directories hash the same set.
|
||||||
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
const distDir = join(webRoot, "dist");
|
const distDir = join(webRoot, "dist");
|
||||||
const stampFile = join(distDir, ".src-hash");
|
const stampFile = join(distDir, ".src-hash");
|
||||||
const stampRelative = "web/dist/.src-hash";
|
const stampRelative = "admin/dist/.src-hash";
|
||||||
|
|
||||||
const inputDirs = ["src", "public"];
|
const inputDirs = ["src", "public"];
|
||||||
const inputFiles = [
|
const inputFiles = [
|
||||||
@@ -26,7 +26,7 @@ const inputFiles = [
|
|||||||
"tsconfig.node.json",
|
"tsconfig.node.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
const staleMessage = "web/dist is stale: rebuild the frontend (npm run build)";
|
const staleMessage = "admin/dist is stale: rebuild the frontend (npm run build)";
|
||||||
|
|
||||||
function fail(message) {
|
function fail(message) {
|
||||||
process.stderr.write(`${message}\n`);
|
process.stderr.write(`${message}\n`);
|
||||||
@@ -39,7 +39,7 @@ function walk(relativeDir) {
|
|||||||
try {
|
try {
|
||||||
entries = readdirSync(absolute, { withFileTypes: true });
|
entries = readdirSync(absolute, { withFileTypes: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fail(`stamp-dist: cannot read web/${relativeDir}: ${err.message}`);
|
fail(`stamp-dist: cannot read admin/${relativeDir}: ${err.message}`);
|
||||||
}
|
}
|
||||||
const found = [];
|
const found = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -57,9 +57,9 @@ function inputSet() {
|
|||||||
const paths = [...inputFiles, ...inputDirs.flatMap(walk)];
|
const paths = [...inputFiles, ...inputDirs.flatMap(walk)];
|
||||||
for (const path of inputFiles) {
|
for (const path of inputFiles) {
|
||||||
try {
|
try {
|
||||||
if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: web/${path} is not a file`);
|
if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: admin/${path} is not a file`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fail(`stamp-dist: cannot stat web/${path}: ${err.message}`);
|
fail(`stamp-dist: cannot stat admin/${path}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sorted by path so the digest does not depend on directory order.
|
// Sorted by path so the digest does not depend on directory order.
|
||||||
+2
-19
@@ -56,23 +56,6 @@ const styles = stylex.create({
|
|||||||
dash: {
|
dash: {
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* A learned name is runtime state, not something the operator typed, so it
|
|
||||||
* reads muted and carries an outlined "learned" tag. The tag is real text —
|
|
||||||
* a screen reader announces it — because colour alone is not an affordance.
|
|
||||||
*/
|
|
||||||
learnedTag: {
|
|
||||||
marginLeft: "0.5rem",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
borderColor: colors.border,
|
|
||||||
borderRadius: "0.25rem",
|
|
||||||
paddingInline: "0.375rem",
|
|
||||||
paddingBlock: "0.125rem",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
lineHeight: "1rem",
|
|
||||||
color: colors.textMuted,
|
|
||||||
},
|
|
||||||
badge: {
|
badge: {
|
||||||
marginLeft: "0.5rem",
|
marginLeft: "0.5rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
@@ -153,9 +136,9 @@ export default function ClientsPage() {
|
|||||||
{client.name !== "" ? (
|
{client.name !== "" ? (
|
||||||
client.name
|
client.name
|
||||||
) : client.learned_name !== "" ? (
|
) : client.learned_name !== "" ? (
|
||||||
<span {...stylex.props(styles.dash)}>
|
<span {...stylex.props(shared.learnedName)}>
|
||||||
{client.learned_name}
|
{client.learned_name}
|
||||||
<span {...stylex.props(styles.learnedTag)}>learned</span>
|
<span {...stylex.props(shared.learnedTag)}>learned</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span {...stylex.props(styles.dash)}>—</span>
|
<span {...stylex.props(styles.dash)}>—</span>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* The client column of the query tables reads as a name wherever one is known,
|
||||||
|
* with the same precedence the Clients page applies: a hand-typed `name` wins,
|
||||||
|
* the reverse-DNS `learned_name` stands in muted behind it, and an address with
|
||||||
|
* neither — including one the loaded list has never seen — stays bare.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { clientsQuery } from "@/lib/queries";
|
||||||
|
import type { Client } from "@/lib/types";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
|
||||||
|
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live stream names clients the loaded list has never seen. Polling folds
|
||||||
|
* them in on the next tick, which keeps the lookup a single cached query
|
||||||
|
* instead of a fetch fired per unknown address.
|
||||||
|
*/
|
||||||
|
const CLIENTS_POLL_MS = 30_000;
|
||||||
|
|
||||||
|
export function useClientNames(): ClientNames {
|
||||||
|
const { data } = useQuery({ ...clientsQuery(), refetchInterval: CLIENTS_POLL_MS });
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
(data ?? []).map((client) => [client.ip, { name: client.name, learned_name: client.learned_name }]),
|
||||||
|
),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
|
||||||
|
const client = names.get(ip);
|
||||||
|
if (client === undefined || (client.name === "" && client.learned_name === "")) {
|
||||||
|
return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
||||||
|
}
|
||||||
|
// The name replaces the address on screen, so the address stays reachable
|
||||||
|
// as the tooltip rather than disappearing from the row entirely.
|
||||||
|
if (client.name !== "") return <span title={ip}>{client.name}</span>;
|
||||||
|
return (
|
||||||
|
<span title={ip} {...stylex.props(shared.learnedName)}>
|
||||||
|
{client.learned_name}
|
||||||
|
<span {...stylex.props(shared.learnedTag)}>learned</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import type { Client, LiveQueryEvent } from "@/lib/types";
|
||||||
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
|
import LiveLogPage from "./LiveLogPage";
|
||||||
|
|
||||||
|
function client(ip: string, name: string, learnedName: string): Client {
|
||||||
|
return {
|
||||||
|
id: Number(ip.split(".").pop()),
|
||||||
|
ip,
|
||||||
|
name,
|
||||||
|
learned_name: learnedName,
|
||||||
|
group_id: 1,
|
||||||
|
group: "default",
|
||||||
|
hand_edited: name !== "",
|
||||||
|
first_seen: 1_700_000_000,
|
||||||
|
last_seen: 1_700_000_100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENTS: Client[] = [
|
||||||
|
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||||
|
client("192.0.2.11", "", "laptop.lan"),
|
||||||
|
client("192.0.2.12", "", ""),
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input) !== "/api/clients") {
|
||||||
|
return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ clients: CLIENTS }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
|
||||||
|
const payload: LiveQueryEvent = {
|
||||||
|
ts,
|
||||||
|
domain,
|
||||||
|
client_ip: "192.0.2.10",
|
||||||
|
qtype: 1,
|
||||||
|
blocked: false,
|
||||||
|
block_reason: "",
|
||||||
|
response_time_us: 500,
|
||||||
|
cache_hit: true,
|
||||||
|
upstream: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { data: JSON.stringify(payload) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const sources: FakeEventSource[] = [];
|
||||||
|
const createEventSource = (url: string) => {
|
||||||
|
const es = new FakeEventSource(url);
|
||||||
|
sources.push(es);
|
||||||
|
return es;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<LiveLogPage createEventSource={createEventSource} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("streams rows, flags blocked ones, and freezes the display", () => {
|
||||||
|
const sources = renderPage();
|
||||||
|
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("query", frame(1000, "ok.example"));
|
||||||
|
sources[0]!.emit(
|
||||||
|
"query",
|
||||||
|
frame(1001, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack", qtype: 28 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("ok.example")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||||
|
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
|
||||||
|
expect(screen.getByText("AAAA")).toBeTruthy();
|
||||||
|
// StyleX compiles to opaque class names, so the check is structural: a blocked
|
||||||
|
// row carries every class a plain row does, plus the ones the flag adds.
|
||||||
|
const blockedRow = screen.getByText("ads.example").closest("tr");
|
||||||
|
const plainRow = screen.getByText("ok.example").closest("tr");
|
||||||
|
const blockedClasses = new Set(blockedRow?.className.split(" "));
|
||||||
|
const plainClasses = plainRow?.className.split(" ") ?? [];
|
||||||
|
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
|
||||||
|
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
|
||||||
|
|
||||||
|
const freeze = screen.getByRole("button", { name: "Freeze" });
|
||||||
|
fireEvent.click(freeze);
|
||||||
|
expect(freeze.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
|
||||||
|
act(() => sources[0]!.emit("query", frame(1002, "later.example")));
|
||||||
|
expect(screen.queryByText("later.example")).toBeNull();
|
||||||
|
expect(screen.getByText(/3 in buffer/)).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||||
|
expect(screen.getByText("later.example")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||||
|
const sources = renderPage();
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" }));
|
||||||
|
sources[0]!.emit("query", frame(1001, "learned.example", { client_ip: "192.0.2.11" }));
|
||||||
|
sources[0]!.emit("query", frame(1002, "nameless.example", { client_ip: "192.0.2.12" }));
|
||||||
|
sources[0]!.emit("query", frame(1003, "stranger.example", { client_ip: "192.0.2.99" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||||
|
const named = await screen.findByText("Kitchen Pi");
|
||||||
|
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||||
|
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||||
|
|
||||||
|
// The cell holds the learned name followed by the tag, so the match is on
|
||||||
|
// the containing span rather than on a bare text node.
|
||||||
|
const learned = screen.getByText(
|
||||||
|
(content, element) => element?.tagName === "SPAN" && content.startsWith("laptop.lan"),
|
||||||
|
);
|
||||||
|
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||||
|
// The affordance is text, not colour, so a screen reader announces it too.
|
||||||
|
expect(within(learned).getByText("learned")).toBeTruthy();
|
||||||
|
|
||||||
|
// A known client with neither name, and a client the loaded list has never
|
||||||
|
// seen, both fall back to the bare address with no tooltip standing in.
|
||||||
|
const nameless = screen.getByText("192.0.2.12");
|
||||||
|
expect(nameless.getAttribute("title")).toBeNull();
|
||||||
|
const stranger = screen.getByText("192.0.2.99");
|
||||||
|
expect(stranger.getAttribute("title")).toBeNull();
|
||||||
|
expect(screen.getByText("stranger.example").closest("tr")?.textContent).toContain("192.0.2.99");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rows stream in as bare IPs while the client list is still loading", async () => {
|
||||||
|
let releaseClients: () => void = () => {};
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
(input: RequestInfo | URL) =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
if (String(input) !== "/api/clients") {
|
||||||
|
resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
releaseClients = () =>
|
||||||
|
resolve(
|
||||||
|
new Response(JSON.stringify({ clients: CLIENTS }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sources = renderPage();
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" })));
|
||||||
|
|
||||||
|
expect(screen.getByText("192.0.2.10")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Kitchen Pi")).toBeNull();
|
||||||
|
|
||||||
|
releaseClients();
|
||||||
|
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated connection failures show the viewer-cap state with a retry button", () => {
|
||||||
|
const sources = renderPage();
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("error");
|
||||||
|
sources[0]!.emit("error");
|
||||||
|
sources[0]!.emit("error");
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { useClientNames } from "@/features/clients/clientNames";
|
||||||
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
||||||
import { RING_CAPACITY } from "./ringBuffer";
|
import { RING_CAPACITY } from "./ringBuffer";
|
||||||
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
||||||
@@ -168,6 +169,7 @@ function StatusPill({ status }: { status: StreamStatus }) {
|
|||||||
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
|
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
|
||||||
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
||||||
const live = useLiveQueries({ createEventSource });
|
const live = useLiveQueries({ createEventSource });
|
||||||
|
const clientNames = useClientNames();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -240,7 +242,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
|||||||
<tbody>
|
<tbody>
|
||||||
{live.rows.map((row) => (
|
{live.rows.map((row) => (
|
||||||
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
|
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
|
||||||
<QueryCells row={row} />
|
<QueryCells row={row} clientNames={clientNames} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
+64
-2
@@ -1,9 +1,29 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createQueryClient } from "@/lib/queryClient";
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
import type { QueriesPage, QueryRow } from "@/lib/types";
|
import type { Client, QueriesPage, QueryRow } from "@/lib/types";
|
||||||
import QueryLogPage from "./QueryLogPage";
|
import QueryLogPage from "./QueryLogPage";
|
||||||
|
|
||||||
|
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
ip,
|
||||||
|
name,
|
||||||
|
learned_name: learnedName,
|
||||||
|
group_id: 1,
|
||||||
|
group: "default",
|
||||||
|
hand_edited: name !== "",
|
||||||
|
first_seen: 1_700_000_000,
|
||||||
|
last_seen: 1_700_000_100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENTS: Client[] = [
|
||||||
|
client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||||
|
client(2, "192.0.2.11", "", "laptop.lan"),
|
||||||
|
client(3, "192.0.2.12", "", ""),
|
||||||
|
];
|
||||||
|
|
||||||
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -48,6 +68,7 @@ beforeEach(() => {
|
|||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
const payload = PAGES[url];
|
const payload = PAGES[url];
|
||||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
return new Response(JSON.stringify(payload), {
|
return new Response(JSON.stringify(payload), {
|
||||||
@@ -90,6 +111,47 @@ test("renders the first page with type names, blocked badge, and formatted cells
|
|||||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
|
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return json({
|
||||||
|
queries: [
|
||||||
|
row(20, "named.example", { client_ip: "192.0.2.10" }),
|
||||||
|
row(19, "learned.example", { client_ip: "192.0.2.11" }),
|
||||||
|
row(18, "nameless.example", { client_ip: "192.0.2.12" }),
|
||||||
|
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
|
||||||
|
],
|
||||||
|
next_before: null,
|
||||||
|
} satisfies QueriesPage);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||||
|
const named = await screen.findByText("Kitchen Pi");
|
||||||
|
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||||
|
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||||
|
|
||||||
|
// The cell holds the learned name followed by the tag, so the match is on
|
||||||
|
// the containing span rather than on a bare text node.
|
||||||
|
const learned = screen.getByText(
|
||||||
|
(content, element) => element?.tagName === "SPAN" && content.startsWith("laptop.lan"),
|
||||||
|
);
|
||||||
|
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||||
|
// The affordance is text, not colour, so a screen reader announces it too.
|
||||||
|
expect(within(learned).getByText("learned")).toBeTruthy();
|
||||||
|
|
||||||
|
// A known client with neither name, and a client the loaded list has never
|
||||||
|
// seen, both fall back to the bare address with no tooltip standing in.
|
||||||
|
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||||
|
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("load more appends the next page and stops at the end of the log", async () => {
|
test("load more appends the next page and stops at the end of the log", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("first.example");
|
await screen.findByText("first.example");
|
||||||
+7
-3
@@ -5,6 +5,7 @@ import * as api from "@/lib/api";
|
|||||||
import { formatMicros, formatTime } from "@/lib/format";
|
import { formatMicros, formatTime } from "@/lib/format";
|
||||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||||
|
import { ClientName, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||||
import { qtypeName } from "./qtype";
|
import { qtypeName } from "./qtype";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
@@ -162,12 +163,14 @@ export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_re
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
|
export function QueryCells({ row, clientNames }: { row: Omit<QueryRow, "id">; clientNames: ClientNames }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
|
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
|
||||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td>
|
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||||
|
<ClientName ip={row.client_ip} names={clientNames} />
|
||||||
|
</td>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||||
<td {...stylex.props(styles.cell)}>
|
<td {...stylex.props(styles.cell)}>
|
||||||
<BlockedCell row={row} />
|
<BlockedCell row={row} />
|
||||||
@@ -212,6 +215,7 @@ export default function QueryLogPage() {
|
|||||||
const [applied, setApplied] = useState<QueriesFilter>({});
|
const [applied, setApplied] = useState<QueriesFilter>({});
|
||||||
|
|
||||||
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
||||||
|
const clientNames = useClientNames();
|
||||||
|
|
||||||
const pages = base.data?.pages ?? [];
|
const pages = base.data?.pages ?? [];
|
||||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||||
@@ -336,7 +340,7 @@ export default function QueryLogPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||||
<QueryCells row={row} />
|
<QueryCells row={row} clientNames={clientNames} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
// declare, and a string outside a literal union.
|
// declare, and a string outside a literal union.
|
||||||
//
|
//
|
||||||
// Regenerate with:
|
// Regenerate with:
|
||||||
// zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts"
|
// zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Blocklist,
|
Blocklist,
|
||||||
@@ -6,4 +6,4 @@
|
|||||||
pub const bytes = @embedFile("contractSamples.gen.ts");
|
pub const bytes = @embedFile("contractSamples.gen.ts");
|
||||||
|
|
||||||
/// Repo-relative path, so a failing assertion names the file to regenerate.
|
/// Repo-relative path, so a failing assertion names the file to regenerate.
|
||||||
pub const path = "web/src/lib/contractSamples.gen.ts";
|
pub const path = "admin/src/lib/contractSamples.gen.ts";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user