rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
dist-placeholder/
|
||||
package-lock.json
|
||||
dist-sourcemap/
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#101418"/>
|
||||
<path d="M16 5l9 4v7c0 6-4 9.5-9 11-5-1.5-9-5-9-11V9z" fill="none" stroke="#6fce8f" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<circle cx="16" cy="15" r="3" fill="#6fce8f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>nxdns</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<style>
|
||||
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; background: #101418; color: #d8dee6; }
|
||||
main { max-width: 40rem; margin: 4rem auto; padding: 0 1.5rem; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 0.25rem; }
|
||||
h1 + p { margin-top: 0; color: #8b98a8; }
|
||||
dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1.5rem; background: #171d24; border: 1px solid #232c36; border-radius: 8px; padding: 1rem 1.25rem; }
|
||||
dt { color: #8b98a8; }
|
||||
dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||
.ok { color: #6fce8f; }
|
||||
.degraded { color: #e0b25b; }
|
||||
.unreachable { color: #e07a6c; }
|
||||
nav { margin-top: 1.5rem; }
|
||||
nav a { color: #7fb3e8; margin-right: 1.25rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>nxdns</h1>
|
||||
<p>DNS sinkhole — the admin interface ships in a later release.</p>
|
||||
<dl>
|
||||
<dt>Status</dt><dd id="status">loading…</dd>
|
||||
<dt>Upstreams</dt><dd id="upstreams">–</dd>
|
||||
<dt>Disk</dt><dd id="disk">–</dd>
|
||||
<dt>Version</dt><dd id="version">–</dd>
|
||||
</dl>
|
||||
<nav>
|
||||
<a href="/metrics">Metrics</a>
|
||||
<a href="/api/health">Health</a>
|
||||
<a href="/api/openapi.yaml">API reference</a>
|
||||
</nav>
|
||||
</main>
|
||||
<script>
|
||||
const el = (id) => document.getElementById(id);
|
||||
fetch("/api/health")
|
||||
.then((r) => r.json())
|
||||
.then((h) => {
|
||||
el("status").textContent = h.status;
|
||||
el("status").className = h.status === "ok" ? "ok" : "degraded";
|
||||
el("upstreams").textContent = h.upstreams.available + " of " + h.upstreams.total + " available";
|
||||
el("disk").textContent = h.disk.state + ", " + Math.round(h.disk.free_bytes / 1048576) + " MiB free";
|
||||
})
|
||||
.catch(() => {
|
||||
el("status").textContent = "unreachable";
|
||||
el("status").className = "unreachable";
|
||||
});
|
||||
fetch("/api/version")
|
||||
.then((r) => r.json())
|
||||
.then((v) => { el("version").textContent = v.version + " (" + v.git_commit + ")"; })
|
||||
.catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>nxdns</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3866
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "nxdns-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "24.19.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "oxlint src vite.config.ts",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"assert-bundled": "node scripts/assert-bundled-packages.mjs"
|
||||
},
|
||||
"prettier": {
|
||||
"useTabs": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stylexjs/stylex": "0.19.0",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-router": "1.170.18",
|
||||
"react": "19.2.8",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylexjs/unplugin": "0.19.0",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.4",
|
||||
"jsdom": "29.1.1",
|
||||
"oxlint": "1.75.0",
|
||||
"prettier": "3.9.6",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.5",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#18181b" />
|
||||
<text x="16" y="22" font-family="ui-monospace, monospace" font-size="13" font-weight="bold" fill="#4ade80" text-anchor="middle">nx</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
// 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).
|
||||
//
|
||||
// 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
|
||||
// modules that went into it, and the artifact `npm run build` produced stays
|
||||
// untouched. Runs from admin/ as `npm run assert-bundled`, on a laptop exactly as
|
||||
// on the runner.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { bundledPackages, comparePackages, formatDiff, recordedPackages } from "./bundledPackages.mjs";
|
||||
|
||||
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const outDir = "dist-sourcemap";
|
||||
const identityFile = join(webRoot, "..", "licenses", "dependency-identity.txt");
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function mapFiles(relativeDir) {
|
||||
const absolute = join(webRoot, relativeDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(absolute, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: cannot read ${relativeDir}: ${err.message}`);
|
||||
}
|
||||
const found = [];
|
||||
for (const entry of entries) {
|
||||
const child = `${relativeDir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...mapFiles(child));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".map")) {
|
||||
found.push(child);
|
||||
}
|
||||
}
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
// The binary npm ci installed, never `npx`: npx silently downloads a package it
|
||||
// cannot find locally, so a wrong working directory would turn a licence check
|
||||
// into an unpinned fetch from the network.
|
||||
try {
|
||||
execFileSync(
|
||||
join(webRoot, "node_modules", ".bin", "vite"),
|
||||
["build", "--sourcemap", "--outDir", outDir, "--emptyOutDir"],
|
||||
{
|
||||
cwd: webRoot,
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: the sourcemap build failed: ${err.message}`);
|
||||
}
|
||||
|
||||
const maps = mapFiles(outDir);
|
||||
if (maps.length === 0) fail("assert-bundled: the sourcemap build produced no .map files; this check cannot run blind");
|
||||
|
||||
const sourceLists = maps.map((path) => {
|
||||
const raw = readFileSync(join(webRoot, path), "utf8");
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: ${path} is not JSON: ${err.message}`);
|
||||
}
|
||||
return Array.isArray(parsed.sources) ? parsed.sources : [];
|
||||
});
|
||||
|
||||
const bundled = bundledPackages(sourceLists);
|
||||
|
||||
let identity;
|
||||
try {
|
||||
identity = readFileSync(identityFile, "utf8");
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: cannot read licenses/dependency-identity.txt: ${err.message}`);
|
||||
}
|
||||
|
||||
const recorded = recordedPackages(identity);
|
||||
if (recorded === null) {
|
||||
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into admin/dist]' section");
|
||||
}
|
||||
if (recorded.length === 0) {
|
||||
fail("assert-bundled: the '[npm packages bundled into admin/dist]' section is empty");
|
||||
}
|
||||
|
||||
const { added, removed } = comparePackages(recorded, bundled);
|
||||
if (added.length !== 0 || removed.length !== 0) {
|
||||
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
|
||||
fail(
|
||||
[
|
||||
"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",
|
||||
"the new list in that section of licenses/dependency-identity.txt.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(`admin/dist bundles exactly the ${bundled.length} recorded packages:\n`);
|
||||
for (const name of bundled) process.stdout.write(`${name}\n`);
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
// Every rule in the built stylesheet must sit inside a cascade layer
|
||||
// (milestone 23). Unlayered author CSS outranks every layer whatever its
|
||||
// selector says, so a single unlayered rule silently beats the StyleX atomic
|
||||
// rules it was written to sit under. That failure renders wrong and passes
|
||||
// every other gate: no test asserts computed style, and the bundler is happy.
|
||||
// It also checks the layer ORDER, which is the invariant that actually matters:
|
||||
// a later layer beats an earlier one, so `reset` has to be declared first.
|
||||
//
|
||||
// What it does not catch, so nobody reads more into a pass than is there: an
|
||||
// unlayered rule that sets only custom properties is allowed, because StyleX
|
||||
// emits its token `:root` block exactly that way and this cannot tell that
|
||||
// block from an override of it; a declaration value containing `@layer` or a
|
||||
// brace inside a string blinds the stripper; and with several stylesheets it
|
||||
// judges each alone, not their load order in the document.
|
||||
//
|
||||
// This check runs from admin/ as part of `npm run build`.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
||||
|
||||
const sheets = readdirSync(distDir).filter((name) => name.endsWith(".css"));
|
||||
if (sheets.length === 0) {
|
||||
console.error("assert-css-layers: no stylesheet in dist/assets — did the build emit one?");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// At-rules that describe a resource or a name rather than styling an element.
|
||||
// They carry no cascade priority against a layer, so being outside one is
|
||||
// correct, and StyleX emits `@property` for its custom properties.
|
||||
const unlayerable = String.raw`@(?:layer|property|keyframes|font-face|counter-style|charset|import)`;
|
||||
|
||||
/** Strip comments, then every balanced block and statement the rule above allows. */
|
||||
function outsideLayers(css) {
|
||||
let rest = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (;;) {
|
||||
const at = rest.search(new RegExp(`${unlayerable}[^{;]*\\{`));
|
||||
if (at === -1) break;
|
||||
let depth = 0;
|
||||
let end = rest.indexOf("{", at);
|
||||
for (let i = end; i < rest.length; i += 1) {
|
||||
if (rest[i] === "{") depth += 1;
|
||||
else if (rest[i] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
rest = rest.slice(0, at) + rest.slice(end + 1);
|
||||
}
|
||||
return rest.replace(new RegExp(`${unlayerable}[^;{}]*;`, "g"), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule that only sets custom properties styles nothing on its own — StyleX
|
||||
* emits its token `:root` block that way, ahead of its layers, and a variable
|
||||
* is consumed through `var()` rather than competing with a layered rule.
|
||||
*/
|
||||
function stylesSomething(body) {
|
||||
return body
|
||||
.split(";")
|
||||
.map((declaration) => declaration.trim())
|
||||
.some((declaration) => declaration.length > 0 && !declaration.startsWith("--"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer names in the order their position is fixed, which is where each name is
|
||||
* first mentioned — a later block under an already-named layer does not move it.
|
||||
*/
|
||||
function layerOrder(css) {
|
||||
const seen = [];
|
||||
for (const [, names] of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@layer\s+([^{;]+)[{;]/g)) {
|
||||
for (const name of names.split(",")) {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length > 0 && !seen.includes(trimmed)) seen.push(trimmed);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
for (const sheet of sheets) {
|
||||
const css = readFileSync(join(distDir, sheet), "utf8");
|
||||
|
||||
// Order is the whole point: a later layer wins, so the reset has to be first.
|
||||
const order = layerOrder(css);
|
||||
if (order.length > 0 && order[0] !== "reset") {
|
||||
console.error(
|
||||
`assert-css-layers: ${sheet} declares layers in the order ${order.join(", ")} — ` +
|
||||
`'reset' must come first or it outranks the StyleX rules written against it.`,
|
||||
);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
const leftover = outsideLayers(css);
|
||||
for (const [, selector, body] of leftover.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
if (!stylesSomething(body)) continue;
|
||||
console.error(
|
||||
`assert-css-layers: ${sheet} styles elements outside every @layer:\n` +
|
||||
` ${selector.trim().slice(0, 80)} { ${body.trim().slice(0, 60)} … }`,
|
||||
);
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error("Wrap it in a layer declared before StyleX's, as admin/src/styles.css does.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`every rule in ${sheets.length === 1 ? "the stylesheet" : `${sheets.length} stylesheets`} sits inside a cascade layer`,
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
// The decisions behind `npm run assert-bundled`, kept separate from the script
|
||||
// that does the I/O so they can be unit-tested (milestone-14 deviation 24).
|
||||
//
|
||||
// The licence inventory has to cover every package whose bytes ship, and the
|
||||
// lockfile does not answer that question: it lists what could be reached, not
|
||||
// what rollup kept. Several packages of the non-dev closure are recorded as
|
||||
// tree-shaken away, and if application code starts importing one of them, no
|
||||
// lockfile, no version and no dependency set changes — only the bundle does. So
|
||||
// the bundle is what this reads.
|
||||
|
||||
const sectionHeading = "[npm packages bundled into admin/dist]";
|
||||
|
||||
// A sourcemap `sources` entry for a dependency ends in
|
||||
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
|
||||
// the last `node_modules/` matters: a nested dependency's path carries two.
|
||||
export function packageFromSource(source) {
|
||||
const marker = "node_modules/";
|
||||
const at = source.lastIndexOf(marker);
|
||||
if (at === -1) return null;
|
||||
const rest = source.slice(at + marker.length);
|
||||
const parts = rest.split("/");
|
||||
if (parts.length === 0 || parts[0] === "") return null;
|
||||
if (parts[0].startsWith("@")) {
|
||||
if (parts.length < 2 || parts[1] === "") return null;
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
/// The sorted, deduplicated package set of a list of sourcemap `sources` arrays.
|
||||
export function bundledPackages(sourceLists) {
|
||||
const found = new Set();
|
||||
for (const sources of sourceLists) {
|
||||
for (const source of sources) {
|
||||
const name = packageFromSource(source);
|
||||
if (name !== null) found.add(name);
|
||||
}
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/// The recorded section of `licenses/dependency-identity.txt`: every non-blank
|
||||
/// line after the heading, up to the next `[section]`.
|
||||
export function recordedPackages(text) {
|
||||
const recorded = new Set();
|
||||
let grabbing = false;
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!grabbing) {
|
||||
if (line === sectionHeading) grabbing = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("[")) break;
|
||||
if (line !== "") recorded.add(line);
|
||||
}
|
||||
return grabbing ? [...recorded].sort() : null;
|
||||
}
|
||||
|
||||
/// What changed, in the two directions that mean different things: a package
|
||||
/// that started shipping needs a licence decision, and one that stopped needs
|
||||
/// the record corrected.
|
||||
export function comparePackages(recorded, bundled) {
|
||||
const inBundle = new Set(bundled);
|
||||
const inRecord = new Set(recorded);
|
||||
return {
|
||||
added: bundled.filter((name) => !inRecord.has(name)),
|
||||
removed: recorded.filter((name) => !inBundle.has(name)),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDiff(recorded, bundled) {
|
||||
const { added, removed } = comparePackages(recorded, bundled);
|
||||
const lines = [];
|
||||
for (const name of removed) lines.push(`-${name}`);
|
||||
for (const name of added) lines.push(`+${name}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
bundledPackages,
|
||||
comparePackages,
|
||||
formatDiff,
|
||||
packageFromSource,
|
||||
recordedPackages,
|
||||
} from "./bundledPackages.mjs";
|
||||
|
||||
describe("packageFromSource", () => {
|
||||
it("reads a plain package name", () => {
|
||||
expect(packageFromSource("../../node_modules/react-dom/client.js")).toBe("react-dom");
|
||||
});
|
||||
|
||||
it("keeps the scope of a scoped package", () => {
|
||||
expect(packageFromSource("../../node_modules/@tanstack/react-query/build/index.js")).toBe(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the last node_modules, so a nested dependency is named correctly", () => {
|
||||
expect(packageFromSource("node_modules/vite/node_modules/@scope/inner/x.js")).toBe("@scope/inner");
|
||||
});
|
||||
|
||||
it("ignores application sources", () => {
|
||||
expect(packageFromSource("src/lib/api.ts")).toBeNull();
|
||||
expect(packageFromSource("../src/main.tsx")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundledPackages", () => {
|
||||
it("sorts and deduplicates across every map", () => {
|
||||
const packages = bundledPackages([
|
||||
["node_modules/react/index.js", "src/main.tsx", "node_modules/react/jsx-runtime.js"],
|
||||
["node_modules/@tanstack/react-router/x.js", "node_modules/react/index.js"],
|
||||
]);
|
||||
expect(packages).toEqual(["@tanstack/react-router", "react"]);
|
||||
});
|
||||
|
||||
it("returns an empty set when nothing came from node_modules", () => {
|
||||
expect(bundledPackages([["src/main.tsx"]])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordedPackages", () => {
|
||||
const identity = [
|
||||
"[some earlier section]",
|
||||
"ignored",
|
||||
"",
|
||||
"[npm packages bundled into admin/dist]",
|
||||
"react",
|
||||
"@tanstack/react-query",
|
||||
"",
|
||||
"react-dom",
|
||||
"",
|
||||
"[a later section]",
|
||||
"not-a-package",
|
||||
].join("\n");
|
||||
|
||||
it("reads only its own section, sorted and deduplicated", () => {
|
||||
expect(recordedPackages(identity)).toEqual(["@tanstack/react-query", "react", "react-dom"]);
|
||||
});
|
||||
|
||||
it("distinguishes a missing section from an empty one", () => {
|
||||
expect(recordedPackages("[other]\nx\n")).toBeNull();
|
||||
expect(recordedPackages("[npm packages bundled into admin/dist]\n\n[next]\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparePackages", () => {
|
||||
it("reports both directions", () => {
|
||||
const { added, removed } = comparePackages(["a", "b"], ["b", "c"]);
|
||||
expect(added).toEqual(["c"]);
|
||||
expect(removed).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("reports nothing when the sets match", () => {
|
||||
expect(comparePackages(["a", "b"], ["a", "b"])).toEqual({ added: [], removed: [] });
|
||||
expect(formatDiff(["a"], ["a"])).toBe("");
|
||||
});
|
||||
|
||||
it("formats a diff the way the failure prints it", () => {
|
||||
expect(formatDiff(["a", "b"], ["b", "c"])).toBe("-a\n+c");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
// Freshness stamp for admin/dist (milestone-15 ruling 5). A stale dist has
|
||||
// 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
|
||||
// build.zig system command. Every path resolves from this file's own location
|
||||
// so both working directories hash the same set.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const distDir = join(webRoot, "dist");
|
||||
const stampFile = join(distDir, ".src-hash");
|
||||
const stampRelative = "admin/dist/.src-hash";
|
||||
|
||||
const inputDirs = ["src", "public"];
|
||||
const inputFiles = [
|
||||
"index.html",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"vite.config.ts",
|
||||
"tsconfig.json",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.node.json",
|
||||
];
|
||||
|
||||
const staleMessage = "admin/dist is stale: rebuild the frontend (npm run build)";
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function walk(relativeDir) {
|
||||
const absolute = join(webRoot, relativeDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(absolute, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
fail(`stamp-dist: cannot read admin/${relativeDir}: ${err.message}`);
|
||||
}
|
||||
const found = [];
|
||||
for (const entry of entries) {
|
||||
const child = `${relativeDir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...walk(child));
|
||||
} else if (entry.isFile()) {
|
||||
found.push(child);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function inputSet() {
|
||||
const paths = [...inputFiles, ...inputDirs.flatMap(walk)];
|
||||
for (const path of inputFiles) {
|
||||
try {
|
||||
if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: admin/${path} is not a file`);
|
||||
} catch (err) {
|
||||
fail(`stamp-dist: cannot stat admin/${path}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// Sorted by path so the digest does not depend on directory order.
|
||||
return paths.sort();
|
||||
}
|
||||
|
||||
function digest() {
|
||||
const hash = createHash("sha256");
|
||||
for (const path of inputSet()) {
|
||||
hash.update(path);
|
||||
hash.update("\0");
|
||||
hash.update(readFileSync(join(webRoot, path)));
|
||||
hash.update("\0");
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
const check = process.argv.includes("--check");
|
||||
const computed = digest();
|
||||
|
||||
if (check) {
|
||||
let recorded;
|
||||
try {
|
||||
recorded = readFileSync(stampFile, "utf8").trim();
|
||||
} catch {
|
||||
fail(staleMessage);
|
||||
}
|
||||
if (recorded !== computed) fail(staleMessage);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(stampFile, `${computed}\n`);
|
||||
} catch (err) {
|
||||
fail(`stamp-dist: cannot write ${stampRelative}: ${err.message}`);
|
||||
}
|
||||
process.stdout.write(`${stampRelative} ${computed}\n`);
|
||||
@@ -0,0 +1,97 @@
|
||||
import { act } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { safeRedirect } from "@/auth/LoginPage";
|
||||
import { AuthProvider, resetAuthProbeForTests } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
resetAuthProbeForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function flushAll() {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLoginRoute() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/login"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
test("429 login shows a ticking countdown and keeps submit disabled until it ends", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input) !== "/api/auth/login") return jsonResponse({ error: "not stubbed" }, 404);
|
||||
const body = JSON.parse(String(init?.body)) as { password: string };
|
||||
if (body.password === "") return jsonResponse({ error: "password required" }, 401);
|
||||
return jsonResponse({ error: "rate limited" }, 429, { "retry-after": "3" });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderLoginRoute();
|
||||
await flushAll();
|
||||
|
||||
const input = screen.getByLabelText("Password");
|
||||
fireEvent.change(input, { target: { value: "wrong" } });
|
||||
fireEvent.submit(input.closest("form") as HTMLFormElement);
|
||||
await flushAll();
|
||||
|
||||
const button = screen.getByRole("button", { name: "Log in" }) as HTMLButtonElement;
|
||||
expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again in 3s.");
|
||||
expect(button.disabled).toBe(true);
|
||||
|
||||
const callsAtLockout = fetchMock.mock.calls.length;
|
||||
fireEvent.submit(input.closest("form") as HTMLFormElement);
|
||||
await flushAll();
|
||||
expect(fetchMock.mock.calls.length).toBe(callsAtLockout);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again in 2s.");
|
||||
expect(button.disabled).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again shortly.");
|
||||
expect(button.disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("safeRedirect only allows same-origin absolute paths", () => {
|
||||
expect(safeRedirect(undefined)).toBe("/");
|
||||
expect(safeRedirect("/queries")).toBe("/queries");
|
||||
expect(safeRedirect("/queries?x=1")).toBe("/queries?x=1");
|
||||
expect(safeRedirect("//evil.example")).toBe("/");
|
||||
expect(safeRedirect("https://evil.example")).toBe("/");
|
||||
expect(safeRedirect("/\\evil.example")).toBe("/");
|
||||
expect(safeRedirect("/\\\\evil.example")).toBe("/");
|
||||
expect(safeRedirect("\\evil")).toBe("/");
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useRouter, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { useAuth } from "@/auth/store";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
/** Login renders outside AppShell, so it paints the page ground itself. */
|
||||
page: {
|
||||
display: "flex",
|
||||
minHeight: "100dvh",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
padding: "1rem",
|
||||
},
|
||||
card: {
|
||||
width: "100%",
|
||||
maxWidth: "24rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
probing: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
marginTop: "1.5rem",
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
submit: {
|
||||
width: "100%",
|
||||
},
|
||||
error: {
|
||||
marginTop: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.danger,
|
||||
},
|
||||
});
|
||||
|
||||
export function safeRedirect(raw: string | undefined): string {
|
||||
if (raw === undefined) return "/";
|
||||
if (!/^\/(?![/\\])/.test(raw) || raw.includes("\\")) return "/";
|
||||
return raw;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, remaining: number | null): string {
|
||||
if (error instanceof ApiError) {
|
||||
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 shortly.";
|
||||
}
|
||||
if (error.status === 503) return "The server is starting or degraded. Try again shortly.";
|
||||
return error.message;
|
||||
}
|
||||
return "Could not reach the server.";
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const { authRequired, probe, login } = useAuth();
|
||||
const router = useRouter();
|
||||
const search = useSearch({ from: "/login" });
|
||||
const redirect = safeRedirect(search.redirect);
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
|
||||
const [remaining, setRemaining] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(retryAfter);
|
||||
if (retryAfter === null) return;
|
||||
const timer = setInterval(() => setRemaining((s) => (s === null || s <= 1 ? 0 : s - 1)), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [error, retryAfter]);
|
||||
|
||||
const lockedOut = remaining !== null && remaining > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (authRequired === false) {
|
||||
router.history.replace(redirect);
|
||||
return;
|
||||
}
|
||||
if (authRequired === null) {
|
||||
probe()
|
||||
.then((required) => {
|
||||
if (!required) router.history.replace(redirect);
|
||||
})
|
||||
.catch((probeError: unknown) => setError(probeError));
|
||||
}
|
||||
}, [authRequired, probe, redirect, router]);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy || lockedOut) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(password);
|
||||
router.history.push(redirect);
|
||||
} catch (loginError) {
|
||||
setError(loginError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...stylex.props(styles.page)}>
|
||||
<section {...stylex.props(styles.card)}>
|
||||
<h1 {...stylex.props(styles.heading)}>nxdns</h1>
|
||||
{authRequired !== true ? (
|
||||
<p {...stylex.props(styles.probing)}>Checking whether a password is required…</p>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||
<div>
|
||||
<label htmlFor="password" {...stylex.props(styles.fieldLabel)}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || lockedOut}
|
||||
{...stylex.props(shared.largePrimaryButton, styles.submit, shared.focusRing)}
|
||||
>
|
||||
{busy ? "Logging in…" : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{error !== null && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{errorMessage(error, remaining)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { act } from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthProvider, resetAuthProbeForTests, useAuth } from "@/auth/store";
|
||||
|
||||
const STORAGE_KEY = "nxdns_auth_required";
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <AuthProvider>{children}</AuthProvider>;
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
resetAuthProbeForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("mounting with nothing stored probes and settles authRequired true on 401", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === "/api/auth/login") return jsonResponse({ error: "password required" }, 401);
|
||||
return jsonResponse({ error: "not stubbed" }, 404);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
expect(result.current.authRequired).toBeNull();
|
||||
|
||||
await waitFor(() => expect(result.current.authRequired).toBe(true));
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
|
||||
test("mounting with nothing stored probes and settles authRequired false when auth is off", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ authenticated: true, auth_required: false })),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
await waitFor(() => expect(result.current.authRequired).toBe(false));
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBe("false");
|
||||
});
|
||||
|
||||
test("a stored value is the fast path: no probe fires on mount", async () => {
|
||||
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
expect(result.current.authRequired).toBe(true);
|
||||
|
||||
await act(async () => {});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("logout swallows a 401 from an already-dead session", async () => {
|
||||
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ error: "unauthorized" }, 401)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
await expect(result.current.logout()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("logout rethrows non-401 errors such as 429", async () => {
|
||||
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ error: "rate limited" }, 429, { "retry-after": "7" })),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
await expect(result.current.logout()).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
status: 429,
|
||||
retryAfter: 7,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ApiError, login as apiLogin, logout as apiLogout } from "@/lib/api";
|
||||
import type { LoginResponse } from "@/lib/types";
|
||||
|
||||
const STORAGE_KEY = "nxdns_auth_required";
|
||||
|
||||
export function readStoredAuthRequired(): boolean | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
return raw === null ? null : raw === "true";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberAuthRequired(value: boolean): void {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, String(value));
|
||||
} catch {
|
||||
// Storage unavailable; the probe will run again next load.
|
||||
}
|
||||
}
|
||||
|
||||
// Deduped across StrictMode double-effects: one empty-password login answers
|
||||
// whether auth is on (401 → on; 200 with auth_required=false → off).
|
||||
let probePromise: Promise<boolean> | null = null;
|
||||
|
||||
function probeAuthRequired(): Promise<boolean> {
|
||||
probePromise ??= apiLogin({ password: "" }).then(
|
||||
(response) => {
|
||||
rememberAuthRequired(response.auth_required);
|
||||
return response.auth_required;
|
||||
},
|
||||
(error: unknown) => {
|
||||
probePromise = null;
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
rememberAuthRequired(true);
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return probePromise;
|
||||
}
|
||||
|
||||
export function resetAuthProbeForTests(): void {
|
||||
probePromise = null;
|
||||
}
|
||||
|
||||
export interface AuthStore {
|
||||
/** null until a login response, a probe, or a stored value settles it. */
|
||||
authRequired: boolean | null;
|
||||
/** Resolves true when a password is required (form must be shown). */
|
||||
probe: () => Promise<boolean>;
|
||||
login: (password: string) => Promise<LoginResponse>;
|
||||
/** Ends the session server-side; swallows an already-dead session's 401. */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthStore | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authRequired, setAuthRequired] = useState<boolean | null>(readStoredAuthRequired);
|
||||
|
||||
const probe = useCallback(async () => {
|
||||
const required = await probeAuthRequired();
|
||||
setAuthRequired(required);
|
||||
return required;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authRequired !== null) return;
|
||||
probe().catch(() => {
|
||||
// Server unreachable; LoginPage's own probe surfaces the error.
|
||||
});
|
||||
}, [authRequired, probe]);
|
||||
|
||||
const login = useCallback(async (password: string) => {
|
||||
const response = await apiLogin({ password });
|
||||
rememberAuthRequired(response.auth_required);
|
||||
setAuthRequired(response.auth_required);
|
||||
return response;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) return;
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthStore>(
|
||||
() => ({ authRequired, probe, login, logout }),
|
||||
[authRequired, probe, login, logout],
|
||||
);
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthStore {
|
||||
const store = useContext(AuthContext);
|
||||
if (store === null) throw new Error("useAuth requires an AuthProvider");
|
||||
return store;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import BlocklistForm, { swallowMutationError } from "./BlocklistForm";
|
||||
|
||||
test("swallowMutationError drops an ApiError and rethrows anything else", () => {
|
||||
expect(() => swallowMutationError(new ApiError(400, "bad url"))).not.toThrow();
|
||||
expect(() => swallowMutationError(new TypeError("cannot read x of undefined"))).toThrow(TypeError);
|
||||
expect(() => swallowMutationError("not an error at all")).toThrow();
|
||||
});
|
||||
|
||||
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
|
||||
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
|
||||
const { rerender } = render(
|
||||
<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={rejecting} onCancel={undefined} />,
|
||||
);
|
||||
const url = screen.getByLabelText("URL") as HTMLInputElement;
|
||||
const name = screen.getByLabelText("Name") as HTMLInputElement;
|
||||
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
|
||||
fireEvent.change(name, { target: { value: "Example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
|
||||
|
||||
await waitFor(() => expect(rejecting).toHaveBeenCalledTimes(1));
|
||||
expect(url.value).toBe("https://example.com/list.txt");
|
||||
expect(name.value).toBe("Example");
|
||||
|
||||
const resolving = vi.fn(() => Promise.resolve());
|
||||
rerender(<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={resolving} onCancel={undefined} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
|
||||
await waitFor(() => expect(url.value).toBe(""));
|
||||
expect(name.value).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1rem",
|
||||
maxWidth: "36rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
cancel: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Drops the rejection the page already renders inline below the form. Anything
|
||||
* else is a bug in this component and must reach the console instead of dying
|
||||
* silently in the submit handler.
|
||||
*/
|
||||
export function swallowMutationError(error: unknown): void {
|
||||
if (error instanceof ApiError) return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
interface BlocklistFormProps {
|
||||
initial?: Blocklist;
|
||||
busy: boolean;
|
||||
/** File authority: the server answers 403, so the submit stays down. */
|
||||
readOnly: boolean;
|
||||
error: Error | null;
|
||||
onSubmit: (input: BlocklistInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
|
||||
const [url, setUrl] = useState(initial?.url ?? "");
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await onSubmit({ url: url.trim(), name: name.trim(), enabled });
|
||||
} catch (error) {
|
||||
swallowMutationError(error);
|
||||
return;
|
||||
}
|
||||
if (initial === undefined) {
|
||||
setUrl("");
|
||||
setName("");
|
||||
setEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
|
||||
<div>
|
||||
<label htmlFor="blocklist-url" {...stylex.props(styles.fieldLabel)}>
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
id="blocklist-url"
|
||||
type="url"
|
||||
required
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="blocklist-name" {...stylex.props(styles.fieldLabel)}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="blocklist-name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
{initial === undefined ? "Add source" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
{...stylex.props(shared.button, styles.cancel, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<InlineError error={error} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import { clearRefreshStatus } from "@/features/blocklists/refreshStore";
|
||||
|
||||
const BLOCKLISTS = {
|
||||
blocklists: [
|
||||
{
|
||||
id: 1,
|
||||
url: "https://example.com/hosts.txt",
|
||||
name: "StevenBlack",
|
||||
enabled: true,
|
||||
is_suggested: true,
|
||||
last_updated: 1700000000,
|
||||
domain_count: 1000,
|
||||
wildcard_count: 10,
|
||||
exception_count: 7,
|
||||
skipped_regex_count: 3,
|
||||
skipped_unsupported_count: 21,
|
||||
checksum: "abc",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
url: "https://example.org/list.txt",
|
||||
name: "Custom",
|
||||
enabled: false,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
domain_count: 0,
|
||||
wildcard_count: 0,
|
||||
exception_count: 0,
|
||||
skipped_regex_count: 0,
|
||||
skipped_unsupported_count: 0,
|
||||
checksum: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"/api/blocklists": BLOCKLISTS,
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
|
||||
let resolveUpdate: ((response: Response) => void) | null;
|
||||
let deleted: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
clearRefreshStatus();
|
||||
resolveUpdate = null;
|
||||
deleted = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (init?.method === "DELETE") {
|
||||
deleted.push(url);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url === "/api/blocklists/update" && init?.method === "POST") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
}
|
||||
const payload = RESPONSES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderBlocklistsRoute(queryClient = createQueryClient()) {
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient);
|
||||
const view = render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return { queryClient, unmount: view.unmount };
|
||||
}
|
||||
|
||||
const SNAPSHOT = {
|
||||
sources: [
|
||||
{
|
||||
id: 1,
|
||||
state: "loaded",
|
||||
loaded: true,
|
||||
last_attempt: 1700000100,
|
||||
last_success: 1700000100,
|
||||
url: "https://example.com/hosts.txt",
|
||||
last_error: "",
|
||||
domains: 1200,
|
||||
wildcards: 12,
|
||||
exceptions: 9,
|
||||
skipped_regex: 4,
|
||||
skipped_unsupported: 17,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("renders the source table and the status empty state", async () => {
|
||||
renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
|
||||
expect(screen.getByText("StevenBlack")).toBeTruthy();
|
||||
expect(screen.getByText("https://example.com/hosts.txt")).toBeTruthy();
|
||||
expect(screen.getByText("Suggested")).toBeTruthy();
|
||||
expect(screen.getByText("1000")).toBeTruthy();
|
||||
expect(screen.getByText("10")).toBeTruthy();
|
||||
expect(screen.getByText("7")).toBeTruthy();
|
||||
expect(screen.getByText("3")).toBeTruthy();
|
||||
expect(screen.getByText("21")).toBeTruthy();
|
||||
expect(screen.getByText("never")).toBeTruthy();
|
||||
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
|
||||
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
||||
).toBeTruthy();
|
||||
|
||||
const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
|
||||
expect(enabledToggle.checked).toBe(true);
|
||||
const disabledToggle = screen.getByLabelText("Custom enabled") as HTMLInputElement;
|
||||
expect(disabledToggle.checked).toBe(false);
|
||||
|
||||
expect(screen.getByText(/run .Update now. to fetch status/)).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("update now disables the button, then replaces the status section from the 202 snapshot", async () => {
|
||||
renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
|
||||
const button = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
|
||||
fireEvent.click(button);
|
||||
|
||||
const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement;
|
||||
expect(pending.disabled).toBe(true);
|
||||
expect(resolveUpdate).not.toBeNull();
|
||||
|
||||
const snapshot = {
|
||||
sources: [
|
||||
{
|
||||
id: 1,
|
||||
state: "loaded",
|
||||
loaded: true,
|
||||
last_attempt: 1700000100,
|
||||
last_success: 1700000100,
|
||||
url: "https://example.com/hosts.txt",
|
||||
last_error: "",
|
||||
domains: 1200,
|
||||
wildcards: 12,
|
||||
exceptions: 9,
|
||||
skipped_regex: 4,
|
||||
skipped_unsupported: 17,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
state: "fetch_failed",
|
||||
loaded: false,
|
||||
last_attempt: 1700000100,
|
||||
last_success: 0,
|
||||
url: "https://example.org/list.txt",
|
||||
last_error: "connect timed out",
|
||||
domains: 0,
|
||||
wildcards: 0,
|
||||
exceptions: 0,
|
||||
skipped_regex: 0,
|
||||
skipped_unsupported: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
resolveUpdate!(
|
||||
new Response(JSON.stringify(snapshot), { status: 202, headers: { "content-type": "application/json" } }),
|
||||
);
|
||||
|
||||
await screen.findByText("loaded");
|
||||
expect(screen.getByText("fetch_failed")).toBeTruthy();
|
||||
expect(screen.getByText("connect timed out")).toBeTruthy();
|
||||
expect(screen.getByText("1200")).toBeTruthy();
|
||||
expect(screen.getByText("12")).toBeTruthy();
|
||||
expect(screen.getByText("9")).toBeTruthy();
|
||||
expect(screen.getByText("4")).toBeTruthy();
|
||||
expect(screen.getByText("17")).toBeTruthy();
|
||||
expect(screen.getAllByRole("columnheader", { name: "Skipped unsupported" })).toHaveLength(2);
|
||||
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
|
||||
// The store notifies one flush before the mutation's success state lands.
|
||||
await screen.findByText(/Update completed/);
|
||||
|
||||
await waitFor(() => {
|
||||
const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
|
||||
expect(idle.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("update now shows a countdown when rate limited with Retry-After", async () => {
|
||||
renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
|
||||
await screen.findByRole("button", { name: "Updating…" });
|
||||
expect(resolveUpdate).not.toBeNull();
|
||||
|
||||
resolveUpdate!(
|
||||
new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "Retry-After": "7" },
|
||||
}),
|
||||
);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
|
||||
});
|
||||
|
||||
test("the refresh snapshot outlives the query cache's gcTime", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { queryClient, unmount } = renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
|
||||
await screen.findByRole("button", { name: "Updating…" });
|
||||
resolveUpdate!(
|
||||
new Response(JSON.stringify(SNAPSHOT), {
|
||||
status: 202,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await screen.findByText("loaded");
|
||||
|
||||
unmount();
|
||||
// Well past the default 5-minute gcTime: an unsubscribed cache entry is
|
||||
// collected by now, which is what used to erase the snapshot.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(6 * 60_000);
|
||||
});
|
||||
|
||||
renderBlocklistsRoute(queryClient);
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
expect(await screen.findByText("loaded")).toBeTruthy();
|
||||
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("delete asks for confirmation, and cancelling sends no request", async () => {
|
||||
renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
test("confirming the delete dialog issues the DELETE for that source", async () => {
|
||||
renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
|
||||
await screen.findByRole("alertdialog");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import {
|
||||
blocklistCreateMutation,
|
||||
blocklistDeleteMutation,
|
||||
blocklistUpdateMutation,
|
||||
blocklistsQuery,
|
||||
blocklistsUpdateNowMutation,
|
||||
} from "@/lib/queries";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import BlocklistForm from "./BlocklistForm";
|
||||
import { useRefreshStatus } from "./refreshStore";
|
||||
import SourceStatusSection from "./SourceStatusSection";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
header: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
done: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
note: {
|
||||
marginTop: "0.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
name: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
badge: {
|
||||
marginLeft: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: colors.border,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.text,
|
||||
},
|
||||
url: {
|
||||
display: "block",
|
||||
maxWidth: "18rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
});
|
||||
|
||||
export default function BlocklistsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
|
||||
const [editing, setEditing] = useState<Blocklist | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
|
||||
|
||||
const create = useMutation(blocklistCreateMutation(queryClient));
|
||||
const save = useMutation(blocklistUpdateMutation(queryClient));
|
||||
const toggle = useMutation(blocklistUpdateMutation(queryClient));
|
||||
const remove = useMutation(blocklistDeleteMutation(queryClient));
|
||||
const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient));
|
||||
|
||||
const sources = useRefreshStatus();
|
||||
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
|
||||
// The refresh below re-fetches the sources the config already declares, so
|
||||
// it stays live in file mode; every other control here writes config.
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
async function submitForm(input: BlocklistInput) {
|
||||
if (editing === null) {
|
||||
await create.mutateAsync(input);
|
||||
} else {
|
||||
await save.mutateAsync({ id: editing.id, input: { ...input, is_suggested: editing.is_suggested } });
|
||||
setEditing(null);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEnabled(b: Blocklist) {
|
||||
toggle.mutate({
|
||||
id: b.id,
|
||||
input: { url: b.url, name: b.name, enabled: !b.enabled, is_suggested: b.is_suggested },
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (pendingDelete === null) return;
|
||||
remove.mutate(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
const formError = editing === null ? create.error : save.error;
|
||||
const tableError = remove.error ?? toggle.error;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div {...stylex.props(styles.header)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateNow.mutate()}
|
||||
disabled={updateNow.isPending}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
{updateNow.isPending ? "Updating…" : "Update now"}
|
||||
</button>
|
||||
</div>
|
||||
{updateNow.isSuccess && !updateNow.isPending && (
|
||||
<p {...stylex.props(styles.done)} role="status">
|
||||
Update completed; source status refreshed below.
|
||||
</p>
|
||||
)}
|
||||
<InlineError error={updateNow.error} />
|
||||
|
||||
{blocklists.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Name</th>
|
||||
<th {...stylex.props(shared.th)}>URL</th>
|
||||
<th {...stylex.props(shared.th)}>Enabled</th>
|
||||
<th {...stylex.props(shared.th)}>Domains</th>
|
||||
<th {...stylex.props(shared.th)}>Wildcards</th>
|
||||
<th {...stylex.props(shared.th)}>Exceptions</th>
|
||||
<th {...stylex.props(shared.th)}>Skipped regex</th>
|
||||
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
|
||||
<th {...stylex.props(shared.th)}>Last updated</th>
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blocklists.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(styles.name)}>{b.name}</span>
|
||||
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(styles.url)} title={b.url}>
|
||||
{b.url}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${b.name} enabled`}
|
||||
checked={b.enabled}
|
||||
disabled={toggle.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onChange={() => toggleEnabled(b)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{b.skipped_unsupported_count}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(b)}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(
|
||||
shared.linkButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(b)}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Both “Skipped” columns count lines nxdns read and did not take. Skipped regex lines are patterns
|
||||
nxdns accepts only from you — adopt one you trust as a regex rule. Skipped unsupported lines are
|
||||
syntax nxdns cannot translate into a DNS decision: cosmetic element hiding, browser-only
|
||||
modifiers. A skipped unsupported count that dwarfs the domain count usually means the list is
|
||||
written for a browser extension, and its DNS or hosts variant will block more here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={tableError} />
|
||||
|
||||
<BlocklistForm
|
||||
key={editing?.id ?? "add"}
|
||||
initial={editing ?? undefined}
|
||||
busy={editing === null ? create.isPending : save.isPending}
|
||||
readOnly={readOnly}
|
||||
error={formError}
|
||||
onSubmit={submitForm}
|
||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||
/>
|
||||
|
||||
<SourceStatusSection sources={sources} namesById={namesById} />
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete blocklist"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { SourceStatus } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
function formatAttempt(unixSeconds: number): string {
|
||||
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
|
||||
}
|
||||
|
||||
interface SourceStatusSectionProps {
|
||||
sources: SourceStatus[] | null;
|
||||
namesById: ReadonlyMap<number, string>;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
section: {
|
||||
marginTop: "2rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
note: {
|
||||
marginTop: "0.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "0.5rem",
|
||||
overflowX: "auto",
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
name: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
url: {
|
||||
marginTop: "0.125rem",
|
||||
display: "block",
|
||||
maxWidth: "16rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** Green has no token: a loaded source is the only success state in the app. */
|
||||
loaded: {
|
||||
color: {
|
||||
default: "oklch(52.7% 0.154 150.069)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(79.2% 0.209 151.711)",
|
||||
},
|
||||
},
|
||||
failed: {
|
||||
color: colors.danger,
|
||||
},
|
||||
absent: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
|
||||
return (
|
||||
<section {...stylex.props(styles.section)}>
|
||||
<h2 {...stylex.props(styles.heading)}>Source status</h2>
|
||||
{sources === null ? (
|
||||
<p {...stylex.props(styles.note)}>
|
||||
No status snapshot yet — run “Update now” to fetch status for every enabled source.
|
||||
</p>
|
||||
) : sources.length === 0 ? (
|
||||
<p {...stylex.props(styles.note)}>The last update ran against no enabled sources.</p>
|
||||
) : (
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Source</th>
|
||||
<th {...stylex.props(shared.th)}>State</th>
|
||||
<th {...stylex.props(shared.th)}>Last attempt</th>
|
||||
<th {...stylex.props(shared.th)}>Last success</th>
|
||||
<th {...stylex.props(shared.th)}>Domains</th>
|
||||
<th {...stylex.props(shared.th)}>Wildcards</th>
|
||||
<th {...stylex.props(shared.th)}>Exceptions</th>
|
||||
<th {...stylex.props(shared.th)}>Skipped regex</th>
|
||||
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
|
||||
<th {...stylex.props(shared.th)}>Last error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id}>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(styles.name)}>
|
||||
{namesById.get(source.id) ?? source.url}
|
||||
</span>
|
||||
<span {...stylex.props(styles.url)}>{source.url}</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(source.loaded ? styles.loaded : styles.failed)}>
|
||||
{source.state}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_attempt)}</td>
|
||||
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_success)}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.domains}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.exceptions}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>
|
||||
{source.skipped_unsupported}
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
{source.last_error === "" ? (
|
||||
<span {...stylex.props(styles.absent)}>—</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.failed)}>{source.last_error}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { SourceStatus } from "@/lib/types";
|
||||
|
||||
// Client UI state, not server state: the snapshot exists only as the 202 body of
|
||||
// POST /api/blocklists/update and no GET can refetch it. Held here so it outlives
|
||||
// the query cache's gcTime instead of vanishing from an unsubscribed cache entry.
|
||||
let snapshot: SourceStatus[] | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): SourceStatus[] | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function setRefreshStatus(sources: SourceStatus[]): void {
|
||||
snapshot = sources;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function clearRefreshStatus(): void {
|
||||
snapshot = null;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function useRefreshStatus(): SourceStatus[] | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { clientUpdateMutation } from "@/lib/queries";
|
||||
import type { Client, Group } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import Dialog from "@/ui/Dialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
client: Client;
|
||||
groups: Group[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
dialogInput: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(clientUpdateMutation(queryClient));
|
||||
const readOnly = useReadOnlyConfig();
|
||||
// Adopting the learned name as a typed one is the natural gesture, but only
|
||||
// where the save can land: under file authority the PUT answers 403, and the
|
||||
// file's declared name is the one that wins.
|
||||
const [name, setName] = useState(client.name === "" && !readOnly ? client.learned_name : client.name);
|
||||
const [groupId, setGroupId] = useState(client.group_id);
|
||||
|
||||
return (
|
||||
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
|
||||
<form
|
||||
{...stylex.props(styles.form)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
mutation.mutate(
|
||||
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label {...stylex.props(styles.fieldLabel)}>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoFocus
|
||||
{...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
label="Group"
|
||||
variant="compactField"
|
||||
value={String(groupId)}
|
||||
onChange={(value) => setGroupId(Number(value))}
|
||||
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||
/>
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
const GROUPS = {
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
};
|
||||
|
||||
const CLIENTS = {
|
||||
clients: [
|
||||
{
|
||||
id: 1,
|
||||
ip: "192.168.1.10",
|
||||
name: "laptop",
|
||||
learned_name: "laptop-1.lan",
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: true,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700003600,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ip: "192.168.1.11",
|
||||
name: "",
|
||||
learned_name: "kids-tablet.lan",
|
||||
group_id: 2,
|
||||
group: "kids",
|
||||
hand_edited: false,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700007200,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
function stubFetch(map: Record<string, unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||
const payload = map[key];
|
||||
if (payload === undefined) {
|
||||
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function renderClientsPage(map: Record<string, unknown>) {
|
||||
stubFetch(map);
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Clients" });
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
"GET /api/clients": CLIENTS,
|
||||
"GET /api/client-prefixes": PREFIXES,
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/version": VERSION,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("renders the client table with group names and one hand-edited badge", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
expect(screen.getByText("192.168.1.10")).toBeTruthy();
|
||||
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||
expect(screen.getByText("laptop")).toBeTruthy();
|
||||
expect(screen.getAllByText("edited")).toHaveLength(1);
|
||||
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a named row shows the typed name and hides the learned one", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
expect(screen.getByText("laptop")).toBeTruthy();
|
||||
expect(screen.queryByText("laptop-1.lan")).toBeNull();
|
||||
});
|
||||
|
||||
test("an unnamed row shows the learned name with the learned affordance", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
// 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("kids-tablet.lan"),
|
||||
);
|
||||
// The affordance is text, not colour, so a screen reader announces it too.
|
||||
expect(within(learned).getByText("learned")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("shows the DNS-activity empty state when there are no clients", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||
|
||||
expect(screen.getByText(/rows appear automatically as devices on the network make dns queries/i)).toBeTruthy();
|
||||
expect(screen.queryByRole("table")).toBeNull();
|
||||
});
|
||||
|
||||
test("edit opens a dialog seeded with the client's name and group", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
||||
// The dialog portals out of the table, so every field query is scoped to it.
|
||||
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
|
||||
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||
// The RAC Select names its trigger with the value and then the label.
|
||||
expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
|
||||
});
|
||||
|
||||
test("the group picker offers every group and reports the choice", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
||||
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
|
||||
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
|
||||
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "kids" }));
|
||||
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
|
||||
});
|
||||
|
||||
test("prefix editor starts clean and dirties on add", async () => {
|
||||
await renderClientsPage(BASE);
|
||||
|
||||
expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24");
|
||||
const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add prefix" }));
|
||||
expect(save.disabled).toBe(false);
|
||||
expect((screen.getByLabelText("Prefix 2") as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { Client } from "@/lib/types";
|
||||
import ClientEditDialog from "./ClientEditDialog";
|
||||
import PrefixesEditor from "./PrefixesEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
/**
|
||||
* Deleting an observed row discards runtime state the file never declared, so
|
||||
* it stays live under file authority; deleting a hand-edited row contradicts
|
||||
* the file and is the one client DELETE the server answers 403 (ruling 7).
|
||||
*/
|
||||
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "48rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
bodyRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
right: {
|
||||
textAlign: "right",
|
||||
},
|
||||
dash: {
|
||||
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: {
|
||||
marginLeft: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: colors.primary,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
color: colors.primaryText,
|
||||
},
|
||||
confirmGroup: {
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
actionGroup: {
|
||||
display: "inline-flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
/**
|
||||
* The accessible name of the actions column, kept out of the visual table
|
||||
* without leaving the accessibility tree.
|
||||
*/
|
||||
});
|
||||
|
||||
export default function ClientsPage() {
|
||||
const { data: clients } = useSuspenseQuery(clientsQuery());
|
||||
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
||||
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||
const [editing, setEditing] = useState<Client | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||
{clients.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||
nothing to create by hand.
|
||||
</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
<th {...stylex.props(styles.cell)}>IP</th>
|
||||
<th {...stylex.props(styles.cell)}>Name</th>
|
||||
<th {...stylex.props(styles.cell)}>Group</th>
|
||||
<th {...stylex.props(styles.cell)}>First seen</th>
|
||||
<th {...stylex.props(styles.cell)}>Last seen</th>
|
||||
<th {...stylex.props(styles.cell)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((client) => (
|
||||
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
{client.name !== "" ? (
|
||||
client.name
|
||||
) : client.learned_name !== "" ? (
|
||||
<span {...stylex.props(styles.dash)}>
|
||||
{client.learned_name}
|
||||
<span {...stylex.props(styles.learnedTag)}>learned</span>
|
||||
</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.dash)}>—</span>
|
||||
)}
|
||||
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.right)}>
|
||||
{confirmingId === client.id ? (
|
||||
<span {...stylex.props(styles.confirmGroup)}>
|
||||
<span {...stylex.props(styles.note)}>
|
||||
Deleted clients re-materialize on their next DNS query.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmingId(null);
|
||||
deleteMutation.mutate(client.id);
|
||||
}}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(null)}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(client)}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? DECLARED_CLIENT_NOTE
|
||||
: undefined
|
||||
}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={deleteMutation.error} />
|
||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||
<PrefixesEditor prefixes={prefixes} groups={groups} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useReducer, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||
import type { ClientPrefix, Group } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
prefixes: ClientPrefix[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
section: {
|
||||
marginTop: "2.5rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.25rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
rows: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem",
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
},
|
||||
row: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
prefixInput: {
|
||||
width: "13rem",
|
||||
},
|
||||
priorityInput: {
|
||||
width: "5rem",
|
||||
},
|
||||
removeButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: "transparent",
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.danger,
|
||||
},
|
||||
validation: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.danger,
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
const dirty = isDirty(state);
|
||||
const fallbackGroupId = defaultGroupId(groups);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
||||
|
||||
const save = () => {
|
||||
const problem = firstProblem(state.rows);
|
||||
setValidation(problem);
|
||||
if (problem !== null) return;
|
||||
mutation.mutate(toInputs(state.rows), {
|
||||
onSuccess: (stored) => dispatch({ type: "reset", prefixes: stored }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section {...stylex.props(styles.section)}>
|
||||
<h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
|
||||
match wins.
|
||||
</p>
|
||||
{state.rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No prefixes configured.</p>
|
||||
) : (
|
||||
<ul {...stylex.props(styles.rows)}>
|
||||
{state.rows.map((row, index) => (
|
||||
<li key={index} {...stylex.props(styles.row)}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Prefix ${index + 1}`}
|
||||
placeholder="192.168.1.0/24"
|
||||
value={row.prefix}
|
||||
onChange={(event) =>
|
||||
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
|
||||
}
|
||||
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
|
||||
/>
|
||||
<Select
|
||||
aria-label={`Group for prefix ${index + 1}`}
|
||||
variant="inline"
|
||||
value={String(row.group_id)}
|
||||
onChange={(value) =>
|
||||
dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
|
||||
}
|
||||
options={groupOptions}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={`Priority for prefix ${index + 1}`}
|
||||
placeholder="100"
|
||||
value={row.priority}
|
||||
onChange={(event) =>
|
||||
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
|
||||
}
|
||||
{...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "remove", index })}
|
||||
{...stylex.props(styles.removeButton, shared.focusRing)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{validation !== null && (
|
||||
<p role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation}
|
||||
</p>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Add prefix
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty || mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
Save prefixes
|
||||
</button>
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValidation(null);
|
||||
dispatch({ type: "reset", prefixes });
|
||||
}}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Discard changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ClientPrefix } from "@/lib/types";
|
||||
import {
|
||||
firstProblem,
|
||||
initPrefixEditor,
|
||||
isDirty,
|
||||
prefixEditorReducer,
|
||||
toInputs,
|
||||
type PrefixEditorState,
|
||||
} from "./prefixEditor";
|
||||
|
||||
const server: ClientPrefix[] = [
|
||||
{ id: 1, prefix: "192.168.1.0/24", group_id: 1, group: "default", priority: 100 },
|
||||
{ id: 2, prefix: "10.0.0.0/8", group_id: 2, group: "kids", priority: 50 },
|
||||
];
|
||||
|
||||
test("init mirrors the server rows into baseline and rows", () => {
|
||||
const state = initPrefixEditor(server);
|
||||
expect(state.rows).toEqual([
|
||||
{ prefix: "192.168.1.0/24", group_id: 1, priority: "100" },
|
||||
{ prefix: "10.0.0.0/8", group_id: 2, priority: "50" },
|
||||
]);
|
||||
expect(state.baseline).toEqual(state.rows);
|
||||
expect(isDirty(state)).toBe(false);
|
||||
});
|
||||
|
||||
test("add appends an empty row with the given group and marks dirty", () => {
|
||||
const state = prefixEditorReducer(initPrefixEditor(server), { type: "add", groupId: 1 });
|
||||
expect(state.rows).toHaveLength(3);
|
||||
expect(state.rows[2]).toEqual({ prefix: "", group_id: 1, priority: "" });
|
||||
expect(isDirty(state)).toBe(true);
|
||||
});
|
||||
|
||||
test("remove drops the row at the index", () => {
|
||||
const state = prefixEditorReducer(initPrefixEditor(server), { type: "remove", index: 0 });
|
||||
expect(state.rows).toEqual([{ prefix: "10.0.0.0/8", group_id: 2, priority: "50" }]);
|
||||
expect(isDirty(state)).toBe(true);
|
||||
});
|
||||
|
||||
test("edit patches a single row", () => {
|
||||
const state = prefixEditorReducer(initPrefixEditor(server), {
|
||||
type: "edit",
|
||||
index: 1,
|
||||
patch: { group_id: 1, priority: "10" },
|
||||
});
|
||||
expect(state.rows[1]).toEqual({ prefix: "10.0.0.0/8", group_id: 1, priority: "10" });
|
||||
expect(state.rows[0]).toEqual(state.baseline[0]);
|
||||
expect(isDirty(state)).toBe(true);
|
||||
});
|
||||
|
||||
test("editing a field back to its baseline value is clean again", () => {
|
||||
let state: PrefixEditorState = initPrefixEditor(server);
|
||||
state = prefixEditorReducer(state, { type: "edit", index: 0, patch: { priority: "7" } });
|
||||
expect(isDirty(state)).toBe(true);
|
||||
state = prefixEditorReducer(state, { type: "edit", index: 0, patch: { priority: "100" } });
|
||||
expect(isDirty(state)).toBe(false);
|
||||
});
|
||||
|
||||
test("reset adopts new server rows and clears dirtiness", () => {
|
||||
let state = prefixEditorReducer(initPrefixEditor(server), { type: "add", groupId: 1 });
|
||||
state = prefixEditorReducer(state, { type: "reset", prefixes: server });
|
||||
expect(isDirty(state)).toBe(false);
|
||||
expect(state.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
|
||||
expect(
|
||||
toInputs([
|
||||
{ prefix: " 192.168.1.0/24 ", group_id: 1, priority: "25" },
|
||||
{ prefix: "10.0.0.0/8", group_id: 2, priority: "" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ prefix: "192.168.1.0/24", group_id: 1, priority: 25 },
|
||||
{ prefix: "10.0.0.0/8", group_id: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
||||
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
|
||||
expect(
|
||||
firstProblem([
|
||||
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
||||
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
||||
]),
|
||||
).toBe("Row 2: priority must be a whole number.");
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
|
||||
|
||||
export interface PrefixRow {
|
||||
prefix: string;
|
||||
group_id: number;
|
||||
/** Raw input text; empty means "use the server default (100)". */
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface PrefixEditorState {
|
||||
baseline: PrefixRow[];
|
||||
rows: PrefixRow[];
|
||||
}
|
||||
|
||||
export type PrefixEditorAction =
|
||||
| { type: "reset"; prefixes: ClientPrefix[] }
|
||||
| { type: "add"; groupId: number }
|
||||
| { type: "remove"; index: number }
|
||||
| { type: "edit"; index: number; patch: Partial<PrefixRow> };
|
||||
|
||||
function fromServer(prefixes: ClientPrefix[]): PrefixRow[] {
|
||||
return prefixes.map((p) => ({ prefix: p.prefix, group_id: p.group_id, priority: String(p.priority) }));
|
||||
}
|
||||
|
||||
export function initPrefixEditor(prefixes: ClientPrefix[]): PrefixEditorState {
|
||||
const rows = fromServer(prefixes);
|
||||
return { baseline: rows, rows };
|
||||
}
|
||||
|
||||
export function prefixEditorReducer(state: PrefixEditorState, action: PrefixEditorAction): PrefixEditorState {
|
||||
switch (action.type) {
|
||||
case "reset":
|
||||
return initPrefixEditor(action.prefixes);
|
||||
case "add":
|
||||
return { ...state, rows: [...state.rows, { prefix: "", group_id: action.groupId, priority: "" }] };
|
||||
case "remove":
|
||||
return { ...state, rows: state.rows.filter((_, i) => i !== action.index) };
|
||||
case "edit":
|
||||
return {
|
||||
...state,
|
||||
rows: state.rows.map((row, i) => (i === action.index ? { ...row, ...action.patch } : row)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function sameRow(a: PrefixRow, b: PrefixRow): boolean {
|
||||
return a.prefix === b.prefix && a.group_id === b.group_id && a.priority === b.priority;
|
||||
}
|
||||
|
||||
export function isDirty(state: PrefixEditorState): boolean {
|
||||
if (state.rows.length !== state.baseline.length) return true;
|
||||
return state.rows.some((row, i) => {
|
||||
const base = state.baseline[i];
|
||||
return base === undefined || !sameRow(row, base);
|
||||
});
|
||||
}
|
||||
|
||||
export function firstProblem(rows: PrefixRow[]): string | null {
|
||||
for (const [i, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
|
||||
const priority = row.priority.trim();
|
||||
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toInputs(rows: PrefixRow[]): ClientPrefixInput[] {
|
||||
return rows.map((row) => {
|
||||
const input: ClientPrefixInput = { prefix: row.prefix.trim(), group_id: row.group_id };
|
||||
const priority = row.priority.trim();
|
||||
if (priority !== "") input.priority = Number(priority);
|
||||
return input;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"/api/stats?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
queries: 1000,
|
||||
blocked: 250,
|
||||
cached: 100,
|
||||
clients: 7,
|
||||
avg_response_time_us: 2345,
|
||||
},
|
||||
"/api/stats/timeseries?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
bucket_seconds: 1800,
|
||||
buckets: [
|
||||
{ ts: 0, queries: 60, blocked: 20, cached: 10 },
|
||||
{ ts: 1800, queries: 40, blocked: 0, cached: 0 },
|
||||
{ ts: 3600, queries: 0, blocked: 0, cached: 0 },
|
||||
],
|
||||
},
|
||||
"/api/stats?period=1h": {
|
||||
period: "1h",
|
||||
since: 0,
|
||||
until: 3600,
|
||||
queries: 12,
|
||||
blocked: 3,
|
||||
cached: 0,
|
||||
clients: 2,
|
||||
avg_response_time_us: null,
|
||||
},
|
||||
"/api/stats/timeseries?period=1h": {
|
||||
period: "1h",
|
||||
since: 0,
|
||||
until: 3600,
|
||||
bucket_seconds: 60,
|
||||
buckets: [],
|
||||
},
|
||||
"/api/health": {
|
||||
status: "degraded",
|
||||
disk: {
|
||||
state: "warn",
|
||||
free_bytes: 400 * 1024 * 1024,
|
||||
db_bytes: 12 * 1024 * 1024,
|
||||
log_bytes: 2048,
|
||||
sample_failures: 0,
|
||||
},
|
||||
upstreams: { available: 1, total: 2 },
|
||||
queries_dropped: 5,
|
||||
writer_failed: false,
|
||||
refreshes_gated: 0,
|
||||
snapshot_generation: 3,
|
||||
},
|
||||
"/api/upstream/health": {
|
||||
upstreams: [
|
||||
{
|
||||
url: "https://dns.example/dns-query",
|
||||
enabled: true,
|
||||
available: false,
|
||||
consecutive_failures: 4,
|
||||
total_successes: 90,
|
||||
total_failures: 10,
|
||||
success_rate: 0.9,
|
||||
last_error: "timeout",
|
||||
},
|
||||
{
|
||||
url: "udp://9.9.9.9:53",
|
||||
enabled: true,
|
||||
available: true,
|
||||
consecutive_failures: 0,
|
||||
total_successes: 100,
|
||||
total_failures: 0,
|
||||
success_rate: 1,
|
||||
last_error: "",
|
||||
},
|
||||
],
|
||||
available: 1,
|
||||
total: 2,
|
||||
},
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
|
||||
// Endpoints forced to fail with a 4xx, which the query client does not retry.
|
||||
let failing: Set<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
failing = new Set();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (failing.has(url)) {
|
||||
return new Response(JSON.stringify({ error: "upstream health unavailable" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const payload = RESPONSES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderDashboard() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
test("dashboard renders stats, chart, disk card, upstream table and health banners", async () => {
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
expect(screen.getByText("1,000")).toBeTruthy();
|
||||
expect(screen.getByText("250")).toBeTruthy();
|
||||
expect(screen.getByText("25.0%")).toBeTruthy();
|
||||
expect(screen.getByText("7")).toBeTruthy();
|
||||
expect(screen.getByText("2.3 ms")).toBeTruthy();
|
||||
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
|
||||
|
||||
expect(screen.getByText("Disk")).toBeTruthy();
|
||||
expect(screen.getByText("warn")).toBeTruthy();
|
||||
expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("12.0 MiB")).toBeTruthy();
|
||||
expect(screen.getByText("2.0 KiB")).toBeTruthy();
|
||||
|
||||
const alerts = screen.getAllByRole("alert");
|
||||
expect(alerts.some((alert) => /disk space low/i.test(alert.textContent ?? ""))).toBe(true);
|
||||
expect(alerts.some((alert) => /5 queries dropped/i.test(alert.textContent ?? ""))).toBe(true);
|
||||
|
||||
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
|
||||
expect(screen.getByText("90.0%")).toBeTruthy();
|
||||
expect(screen.getByText("100.0%")).toBeTruthy();
|
||||
expect(screen.getByText("timeout")).toBeTruthy();
|
||||
expect(screen.getByText("1/2 available")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("period picker refetches stats and shows the empty chart state", async () => {
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
||||
|
||||
await screen.findByText("12");
|
||||
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
|
||||
await screen.findByText("No queries in this period.");
|
||||
expect(screen.getByText("—", { selector: "span" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("one failing endpoint degrades its own widget on cold navigation", async () => {
|
||||
failing.add("/api/upstream/health");
|
||||
renderDashboard();
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
// The page renders; only the upstream widget carries the error.
|
||||
await screen.findByText("upstream health unavailable");
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
expect(screen.queryByText("Request failed (400)")).toBeNull();
|
||||
|
||||
expect(screen.getByText("1,000")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||
expect(screen.getByText("Disk")).toBeTruthy();
|
||||
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
|
||||
import type { Period } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import DiskCard from "./DiskCard";
|
||||
import HealthBanners from "./HealthBanners";
|
||||
import StatCards from "./StatCards";
|
||||
import TimeseriesChart from "./TimeseriesChart";
|
||||
import UpstreamHealthTable from "./UpstreamHealthTable";
|
||||
|
||||
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
|
||||
|
||||
const styles = stylex.create({
|
||||
page: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
},
|
||||
titleRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
periodGroup: {
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
period: {
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.625rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
||||
periodSelected: {
|
||||
backgroundColor: {
|
||||
default: "oklch(92% 0.004 286.32)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||
},
|
||||
color: colors.text,
|
||||
fontWeight: 500,
|
||||
},
|
||||
periodIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** Dynamic: the caller sizes the placeholder to the widget it stands in for. */
|
||||
skeletonHeight: (height: number) => ({ height }),
|
||||
skeleton: {
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: {
|
||||
default: "oklch(92% 0.004 286.32)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
|
||||
},
|
||||
},
|
||||
/** The chart takes two thirds beside the disk card from `lg`, one column below. */
|
||||
panelGrid: {
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 1024px)": "2fr 1fr",
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
||||
return (
|
||||
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
|
||||
{PERIODS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={option === period}
|
||||
onClick={() => onChange(option)}
|
||||
{...stylex.props(
|
||||
styles.period,
|
||||
option === period ? styles.periodSelected : styles.periodIdle,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Skeleton({ height }: { height: number }) {
|
||||
return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [period, setPeriod] = useState<Period>("24h");
|
||||
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
|
||||
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
|
||||
const health = useQuery(healthQuery());
|
||||
const upstreamHealth = useQuery(upstreamHealthQuery());
|
||||
|
||||
return (
|
||||
<section {...stylex.props(styles.page)}>
|
||||
<div {...stylex.props(styles.titleRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Dashboard</h1>
|
||||
<PeriodPicker period={period} onChange={setPeriod} />
|
||||
</div>
|
||||
|
||||
{health.data !== undefined && <HealthBanners health={health.data} />}
|
||||
|
||||
{stats.isError ? (
|
||||
<InlineError error={stats.error} onRetry={() => void stats.refetch()} />
|
||||
) : stats.data === undefined ? (
|
||||
<Skeleton height={76} />
|
||||
) : (
|
||||
<StatCards stats={stats.data} />
|
||||
)}
|
||||
|
||||
<div {...stylex.props(styles.panelGrid)}>
|
||||
<section {...stylex.props(styles.panel)}>
|
||||
<h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
|
||||
{timeseries.isError ? (
|
||||
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
|
||||
) : timeseries.data === undefined ? (
|
||||
<Skeleton height={240} />
|
||||
) : (
|
||||
<TimeseriesChart data={timeseries.data} />
|
||||
)}
|
||||
</section>
|
||||
{health.data === undefined ? <Skeleton height={160} /> : <DiskCard disk={health.data.disk} />}
|
||||
</div>
|
||||
|
||||
{upstreamHealth.isError ? (
|
||||
<InlineError error={upstreamHealth.error} onRetry={() => void upstreamHealth.refetch()} />
|
||||
) : upstreamHealth.data === undefined ? (
|
||||
<Skeleton height={120} />
|
||||
) : (
|
||||
<UpstreamHealthTable health={upstreamHealth.data} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { Health } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
card: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
badge: {
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
/**
|
||||
* The badge fills are their own three-step scale, not the `danger`/`warn`
|
||||
* banner tokens: they read as a tinted chip against a raised card, where a
|
||||
* banner fill would be too heavy.
|
||||
*/
|
||||
ok: {
|
||||
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
|
||||
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
|
||||
},
|
||||
warn: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
|
||||
},
|
||||
critical: {
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
|
||||
},
|
||||
list: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
row: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
term: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
function stateStyle(state: Health["disk"]["state"]) {
|
||||
if (state === "critical") return styles.critical;
|
||||
return state === "warn" ? styles.warn : styles.ok;
|
||||
}
|
||||
|
||||
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
|
||||
return (
|
||||
<section {...stylex.props(styles.card)}>
|
||||
<h2 {...stylex.props(styles.heading)}>
|
||||
Disk
|
||||
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
|
||||
</h2>
|
||||
<dl {...stylex.props(styles.list)}>
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<dt {...stylex.props(styles.term)}>Free</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
|
||||
</div>
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<dt {...stylex.props(styles.term)}>Database</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
|
||||
</div>
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<dt {...stylex.props(styles.term)}>Logs</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { Health } from "@/lib/types";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
stack: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
banner: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
warn: {
|
||||
borderColor: colors.warnBorder,
|
||||
backgroundColor: colors.warnSurface,
|
||||
color: colors.warnText,
|
||||
},
|
||||
critical: {
|
||||
borderColor: colors.dangerBorder,
|
||||
backgroundColor: colors.dangerSurface,
|
||||
color: colors.dangerText,
|
||||
},
|
||||
});
|
||||
|
||||
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
|
||||
return (
|
||||
<p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HealthBanners({ health }: { health: Health }) {
|
||||
const banners: React.ReactNode[] = [];
|
||||
if (health.disk.state !== "ok") {
|
||||
banners.push(
|
||||
<Banner key="disk" tone={health.disk.state === "critical" ? "critical" : "warn"}>
|
||||
{health.disk.state === "critical"
|
||||
? `Disk critically low: ${formatBytes(health.disk.free_bytes)} free. Blocklist updates and log flushes are stopped.`
|
||||
: `Disk space low: ${formatBytes(health.disk.free_bytes)} free.`}
|
||||
</Banner>,
|
||||
);
|
||||
}
|
||||
if (health.writer_failed) {
|
||||
banners.push(
|
||||
<Banner key="writer" tone="critical">
|
||||
Query log writer failed; new queries are not being persisted.
|
||||
</Banner>,
|
||||
);
|
||||
}
|
||||
if (health.queries_dropped > 0) {
|
||||
banners.push(
|
||||
<Banner key="dropped" tone="warn">
|
||||
{health.queries_dropped.toLocaleString()} queries dropped from the log buffer.
|
||||
</Banner>,
|
||||
);
|
||||
}
|
||||
if (banners.length === 0) return null;
|
||||
return <div {...stylex.props(styles.stack)}>{banners}</div>;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatMicros } from "@/lib/format";
|
||||
import type { StatsTotals } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const numberFormat = new Intl.NumberFormat();
|
||||
|
||||
const styles = stylex.create({
|
||||
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
|
||||
grid: {
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
|
||||
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
card: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
label: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
value: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
detail: {
|
||||
marginLeft: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
function percentOf(part: number, total: number): string | null {
|
||||
if (total === 0) return null;
|
||||
return `${((part / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
|
||||
return (
|
||||
<div {...stylex.props(styles.card)}>
|
||||
<dt {...stylex.props(styles.label)}>{label}</dt>
|
||||
<dd>
|
||||
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
|
||||
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatCards({ stats }: { stats: StatsTotals }) {
|
||||
return (
|
||||
<dl {...stylex.props(styles.grid)}>
|
||||
<Card label="Queries" value={numberFormat.format(stats.queries)} />
|
||||
<Card
|
||||
label="Blocked"
|
||||
value={numberFormat.format(stats.blocked)}
|
||||
detail={percentOf(stats.blocked, stats.queries)}
|
||||
/>
|
||||
<Card
|
||||
label="Cached"
|
||||
value={numberFormat.format(stats.cached)}
|
||||
detail={percentOf(stats.cached, stats.queries)}
|
||||
/>
|
||||
<Card label="Clients" value={numberFormat.format(stats.clients)} />
|
||||
<Card
|
||||
label="Avg response"
|
||||
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
|
||||
/>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { StatsTimeseries } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
|
||||
|
||||
// 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).
|
||||
const SERIES = [
|
||||
{ key: "blocked", label: "Blocked", color: "#ef4444" },
|
||||
{ key: "cached", label: "Cached", color: "#059669" },
|
||||
{ key: "other", label: "Other", color: "#3b82f6" },
|
||||
] as const;
|
||||
|
||||
const CHART_HEIGHT = 240;
|
||||
const FALLBACK_WIDTH = 640;
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
chartRoot: {
|
||||
position: "relative",
|
||||
},
|
||||
tooltip: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
top: "0.5rem",
|
||||
zIndex: 10,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
/** Dynamic: the tooltip flips to whichever side of the bar has room. */
|
||||
tooltipLeft: (left: number) => ({ left, right: null }),
|
||||
tooltipRight: (right: number) => ({ left: null, right }),
|
||||
tooltipTitle: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
tooltipList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.125rem",
|
||||
marginTop: "0.25rem",
|
||||
},
|
||||
tooltipRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
},
|
||||
tooltipTerm: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
swatchSmall: {
|
||||
width: "0.5rem",
|
||||
height: "0.5rem",
|
||||
},
|
||||
swatchLarge: {
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
},
|
||||
gridLine: {
|
||||
stroke: colors.border,
|
||||
},
|
||||
axisLine: {
|
||||
stroke: colors.borderStrong,
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
legend: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
columnGap: "1rem",
|
||||
rowGap: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
legendItem: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
});
|
||||
|
||||
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
setWidth(el.clientWidth);
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return [ref, width];
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
function formatTick(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
function barSummary(bar: BarLayout): string {
|
||||
return `${formatTime(bar.bucket.ts)}: ${bar.bucket.queries} queries, ${bar.bucket.blocked} blocked, ${bar.bucket.cached} cached`;
|
||||
}
|
||||
|
||||
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
|
||||
const centerX = bar.slot.x + bar.slot.width / 2;
|
||||
const leftHalf = centerX < chartWidth / 2;
|
||||
const side = leftHalf
|
||||
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
|
||||
: styles.tooltipRight(chartWidth - centerX + 8);
|
||||
return (
|
||||
<div {...stylex.props(styles.tooltip, side)}>
|
||||
<div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
|
||||
<dl {...stylex.props(styles.tooltipList)}>
|
||||
<div {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
|
||||
</div>
|
||||
{SERIES.map((series) => (
|
||||
<div key={series.key} {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
|
||||
/>
|
||||
{series.label}
|
||||
</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>
|
||||
{series.key === "other" ? bar.other : bar.bucket[series.key]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
||||
const [containerRef, measuredWidth] = useContainerWidth();
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
|
||||
|
||||
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const layout = layoutTimeseries(data.buckets, width, CHART_HEIGHT);
|
||||
const baseline = layout.plot.y + layout.plot.height;
|
||||
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.chartRoot)}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{layout.yTicks.map((tick) => (
|
||||
<g key={tick.value}>
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
{...stylex.props(styles.gridLine)}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
>
|
||||
{compact.format(tick.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={baseline}
|
||||
y2={baseline}
|
||||
{...stylex.props(styles.axisLine)}
|
||||
/>
|
||||
{layout.xTicks.map((tick) => (
|
||||
<text
|
||||
key={tick.ts}
|
||||
x={tick.x}
|
||||
y={baseline + 14}
|
||||
textAnchor="middle"
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
>
|
||||
{formatTick(tick.ts, data.bucket_seconds)}
|
||||
</text>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<g key={bar.bucket.ts} opacity={hovered === null || hovered === i ? 1 : 0.55}>
|
||||
{SERIES.map((series) => {
|
||||
const rect = bar.segments[series.key];
|
||||
if (rect.height <= 0) return null;
|
||||
return (
|
||||
<rect
|
||||
key={series.key}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
fill={series.color}
|
||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
{layout.bars.map((bar, i) => (
|
||||
<rect
|
||||
key={bar.bucket.ts}
|
||||
x={bar.slot.x}
|
||||
y={bar.slot.y}
|
||||
width={bar.slot.width}
|
||||
height={bar.slot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
>
|
||||
<title>{barSummary(bar)}</title>
|
||||
</rect>
|
||||
))}
|
||||
</svg>
|
||||
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{SERIES.map((series) => (
|
||||
<li key={series.key} {...stylex.props(styles.legendItem)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
|
||||
/>
|
||||
{series.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<table {...stylex.props(shared.srOnly)}>
|
||||
<caption>Queries per time bucket</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Queries</th>
|
||||
<th scope="col">Blocked</th>
|
||||
<th scope="col">Cached</th>
|
||||
<th scope="col">Other</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{layout.bars.map((bar) => (
|
||||
<tr key={bar.bucket.ts}>
|
||||
<th scope="row">{formatTime(bar.bucket.ts)}</th>
|
||||
<td>{bar.bucket.queries}</td>
|
||||
<td>{bar.bucket.blocked}</td>
|
||||
<td>{bar.bucket.cached}</td>
|
||||
<td>{bar.other}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import type { UpstreamHealth } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
card: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
heading: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
paddingInline: "1rem",
|
||||
paddingTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
count: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 400,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
empty: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
overflowX: "auto",
|
||||
},
|
||||
table: {
|
||||
marginTop: "0.5rem",
|
||||
width: "100%",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
textAlign: "left",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
th: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
thRight: {
|
||||
textAlign: "right",
|
||||
},
|
||||
/** No hairline under the last row: the card border already closes the table. */
|
||||
row: {
|
||||
borderBottomWidth: { default: 1, ":last-child": 0 },
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
cellRight: {
|
||||
textAlign: "right",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
bad: {
|
||||
color: colors.danger,
|
||||
},
|
||||
});
|
||||
|
||||
function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
|
||||
const bad = value === badValue;
|
||||
return <span {...stylex.props(bad && styles.bad)}>{value ? "yes" : "no"}</span>;
|
||||
}
|
||||
|
||||
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
|
||||
return (
|
||||
<section {...stylex.props(styles.card)}>
|
||||
<h2 {...stylex.props(styles.heading)}>
|
||||
Upstreams
|
||||
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
||||
{health.available}/{health.total} available
|
||||
</span>
|
||||
</h2>
|
||||
{health.upstreams.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
|
||||
) : (
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
URL
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
Enabled
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
Available
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||
Failures
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||
Success rate
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.th)}>
|
||||
Last error
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{health.upstreams.map((upstream) => (
|
||||
<tr key={upstream.url} {...stylex.props(styles.row)}>
|
||||
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>{upstream.url}</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<YesNo value={upstream.enabled} badValue={false} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<YesNo value={upstream.available} badValue={false} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{upstream.total_failures}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||
{(upstream.success_rate * 100).toFixed(1)}%
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
|
||||
{upstream.last_error || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Bucket } from "@/lib/types";
|
||||
import { MARGIN, isEmptyTimeseries, layoutTimeseries, niceTicks } from "./chartLayout";
|
||||
|
||||
function bucket(ts: number, queries: number, blocked = 0, cached = 0): Bucket {
|
||||
return { ts, queries, blocked, cached };
|
||||
}
|
||||
|
||||
describe("niceTicks", () => {
|
||||
test("zero max yields a single zero tick", () => {
|
||||
expect(niceTicks(0)).toEqual([0]);
|
||||
});
|
||||
|
||||
test("picks a 1/2/5 step and extends past max", () => {
|
||||
expect(niceTicks(7)).toEqual([0, 2, 4, 6, 8]);
|
||||
expect(niceTicks(100)).toEqual([0, 50, 100]);
|
||||
expect(niceTicks(1234)).toEqual([0, 500, 1000, 1500]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmptyTimeseries", () => {
|
||||
test("true for no buckets and for all-zero buckets", () => {
|
||||
expect(isEmptyTimeseries([])).toBe(true);
|
||||
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 0)])).toBe(true);
|
||||
});
|
||||
|
||||
test("false when any bucket has queries", () => {
|
||||
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 3)])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layoutTimeseries", () => {
|
||||
test("segment heights are proportional and stack to the queries total", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 100, 40, 10), bucket(60, 50, 0, 0)], 480, 240);
|
||||
const plotHeight = 240 - MARGIN.top - MARGIN.bottom;
|
||||
const baseline = MARGIN.top + plotHeight;
|
||||
const [first, second] = layout.bars;
|
||||
|
||||
expect(layout.scaleMax).toBe(100);
|
||||
expect(first.other).toBe(50);
|
||||
expect(first.segments.blocked.height).toBeCloseTo(plotHeight * 0.4);
|
||||
expect(first.segments.cached.height).toBeCloseTo(plotHeight * 0.1);
|
||||
expect(first.segments.other.height).toBeCloseTo(plotHeight * 0.5);
|
||||
expect(first.segments.blocked.y + first.segments.blocked.height).toBeCloseTo(baseline);
|
||||
expect(first.segments.cached.y + first.segments.cached.height).toBeCloseTo(first.segments.blocked.y);
|
||||
expect(first.segments.other.y + first.segments.other.height).toBeCloseTo(first.segments.cached.y);
|
||||
expect(first.segments.other.y).toBeCloseTo(MARGIN.top);
|
||||
expect(second.segments.other.height).toBeCloseTo(plotHeight * 0.5);
|
||||
});
|
||||
|
||||
test("clamps other at zero when blocked + cached exceed queries", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 10, 8, 5)], 480, 240);
|
||||
expect(layout.bars[0].other).toBe(0);
|
||||
expect(layout.bars[0].segments.other.height).toBe(0);
|
||||
});
|
||||
|
||||
test("zero data still lays out zero-height bars on a unit scale", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 0), bucket(60, 0)], 480, 240);
|
||||
expect(layout.scaleMax).toBe(1);
|
||||
expect(layout.bars).toHaveLength(2);
|
||||
for (const bar of layout.bars) {
|
||||
expect(bar.segments.blocked.height).toBe(0);
|
||||
expect(bar.segments.cached.height).toBe(0);
|
||||
expect(bar.segments.other.height).toBe(0);
|
||||
}
|
||||
expect(layout.yTicks).toEqual([{ value: 0, y: MARGIN.top + (240 - MARGIN.top - MARGIN.bottom) }]);
|
||||
});
|
||||
|
||||
test("single bucket fills the plot width minus the gap", () => {
|
||||
const layout = layoutTimeseries([bucket(0, 5, 1, 1)], 480, 240);
|
||||
const plotWidth = 480 - MARGIN.left - MARGIN.right;
|
||||
const bar = layout.bars[0];
|
||||
expect(bar.slot.width).toBeCloseTo(plotWidth);
|
||||
expect(bar.segments.blocked.width).toBeCloseTo(plotWidth - 2);
|
||||
expect(bar.segments.blocked.x).toBeCloseTo(MARGIN.left + 1);
|
||||
expect(layout.xTicks).toEqual([{ ts: 0, x: MARGIN.left + plotWidth / 2 }]);
|
||||
});
|
||||
|
||||
test("x ticks thin out when buckets outnumber the label budget", () => {
|
||||
const buckets = Array.from({ length: 168 }, (_, i) => bucket(i * 3600, i));
|
||||
const layout = layoutTimeseries(buckets, 800, 240);
|
||||
expect(layout.xTicks.length).toBeLessThan(buckets.length / 10);
|
||||
expect(layout.xTicks[0].ts).toBe(0);
|
||||
const xs = layout.xTicks.map((tick) => tick.x);
|
||||
expect([...xs].sort((a, b) => a - b)).toEqual(xs);
|
||||
});
|
||||
|
||||
test("empty bucket list yields no bars and no x ticks", () => {
|
||||
const layout = layoutTimeseries([], 480, 240);
|
||||
expect(layout.bars).toEqual([]);
|
||||
expect(layout.xTicks).toEqual([]);
|
||||
expect(layout.scaleMax).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Bucket } from "@/lib/types";
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BarLayout {
|
||||
bucket: Bucket;
|
||||
/** queries - blocked - cached, clamped at 0. */
|
||||
other: number;
|
||||
slot: Rect;
|
||||
segments: {
|
||||
blocked: Rect;
|
||||
cached: Rect;
|
||||
other: Rect;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChartLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
plot: Rect;
|
||||
scaleMax: number;
|
||||
bars: BarLayout[];
|
||||
yTicks: { value: number; y: number }[];
|
||||
xTicks: { ts: number; x: number }[];
|
||||
}
|
||||
|
||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||
const BAR_GAP = 2;
|
||||
const MIN_X_LABEL_PX = 90;
|
||||
|
||||
/** Tick values from 0 upward in a 1/2/5 step, extended until the last tick covers `max`. */
|
||||
export function niceTicks(max: number, targetCount = 4): number[] {
|
||||
if (max <= 0) return [0];
|
||||
const rawStep = max / targetCount;
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||
const normalized = rawStep / magnitude;
|
||||
const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
|
||||
const ticks: number[] = [];
|
||||
for (let value = 0; ; value += step) {
|
||||
ticks.push(value);
|
||||
if (value >= max) break;
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function isEmptyTimeseries(buckets: Bucket[]): boolean {
|
||||
return buckets.every((bucket) => bucket.queries === 0);
|
||||
}
|
||||
|
||||
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
|
||||
const plot: Rect = {
|
||||
x: MARGIN.left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||
height: Math.max(0, height - MARGIN.top - MARGIN.bottom),
|
||||
};
|
||||
const maxQueries = buckets.reduce((max, bucket) => Math.max(max, bucket.queries), 0);
|
||||
const tickValues = niceTicks(maxQueries);
|
||||
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
|
||||
const baseline = plot.y + plot.height;
|
||||
const toHeight = (value: number) => (value / scaleMax) * plot.height;
|
||||
|
||||
const slotWidth = buckets.length > 0 ? plot.width / buckets.length : 0;
|
||||
const barWidth = Math.max(1, slotWidth - BAR_GAP);
|
||||
|
||||
const bars: BarLayout[] = buckets.map((bucket, i) => {
|
||||
const slotX = plot.x + i * slotWidth;
|
||||
const barX = slotX + (slotWidth - barWidth) / 2;
|
||||
const other = Math.max(0, bucket.queries - bucket.blocked - bucket.cached);
|
||||
const blockedH = toHeight(bucket.blocked);
|
||||
const cachedH = toHeight(bucket.cached);
|
||||
const otherH = toHeight(other);
|
||||
return {
|
||||
bucket,
|
||||
other,
|
||||
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
|
||||
segments: {
|
||||
blocked: { x: barX, y: baseline - blockedH, width: barWidth, height: blockedH },
|
||||
cached: { x: barX, y: baseline - blockedH - cachedH, width: barWidth, height: cachedH },
|
||||
other: { x: barX, y: baseline - blockedH - cachedH - otherH, width: barWidth, height: otherH },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const yTicks = tickValues.map((value) => ({ value, y: baseline - toHeight(value) }));
|
||||
|
||||
const labelStep =
|
||||
buckets.length > 0 && plot.width > 0
|
||||
? Math.max(1, Math.ceil((buckets.length * MIN_X_LABEL_PX) / plot.width))
|
||||
: 1;
|
||||
const xTicks = bars
|
||||
.filter((_, i) => i % labelStep === 0)
|
||||
.map((bar) => ({ ts: bar.bucket.ts, x: bar.slot.x + bar.slot.width / 2 }));
|
||||
|
||||
return { width, height, plot, scaleMax, bars, yTicks, xTicks };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||
import type { Blocklist } from "@/lib/types";
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
interface Props {
|
||||
groupId: number;
|
||||
blocklists: Blocklist[];
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
note: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
root: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
list: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
buttonRow: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const sources = useQuery(groupSourcesQuery(groupId));
|
||||
const mutation = useMutation(groupSourcesPutMutation(queryClient));
|
||||
const [selected, setSelected] = useState<number[] | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
if (sources.isPending) {
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.note)}>
|
||||
Loading sources…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (sources.isError) return <InlineError error={sources.error} />;
|
||||
|
||||
if (blocklists.length === 0) {
|
||||
return <p {...stylex.props(styles.note)}>No blocklist sources exist yet — add them on the Blocklists page.</p>;
|
||||
}
|
||||
|
||||
const current = selected ?? sources.data;
|
||||
const dirty = !sameSet(current, sources.data);
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.root)}>
|
||||
<ul {...stylex.props(styles.list)}>
|
||||
{blocklists.map((blocklist) => (
|
||||
<li key={blocklist.id}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={current.includes(blocklist.id)}
|
||||
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
{blocklist.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || mutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onClick={() =>
|
||||
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
||||
}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
Save sources
|
||||
</button>
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import type { Mock } from "vitest";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
const GROUPS = {
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
};
|
||||
|
||||
const BLOCKLISTS = {
|
||||
blocklists: [
|
||||
{
|
||||
id: 1,
|
||||
url: "https://example.com/ads.txt",
|
||||
name: "Ads",
|
||||
enabled: true,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
domain_count: 100,
|
||||
wildcard_count: 0,
|
||||
skipped_regex_count: 0,
|
||||
checksum: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
url: "https://example.com/malware.txt",
|
||||
name: "Malware",
|
||||
enabled: true,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
domain_count: 50,
|
||||
wildcard_count: 0,
|
||||
skipped_regex_count: 0,
|
||||
checksum: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
const BASE = {
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/blocklists": BLOCKLISTS,
|
||||
"GET /api/version": VERSION,
|
||||
"GET /api/groups/2/sources": { source_ids: [1] },
|
||||
"PUT /api/groups/2/sources": { source_ids: [1, 2] },
|
||||
};
|
||||
|
||||
function stubFetch(map: Record<string, unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||
const payload = map[key];
|
||||
if (payload === undefined) {
|
||||
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function renderGroupsPage(map: Record<string, unknown>) {
|
||||
stubFetch(map);
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/groups"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Groups" });
|
||||
}
|
||||
|
||||
function groupRow(name: string): HTMLElement {
|
||||
const row = screen.getByText(name).closest("li");
|
||||
if (row === null) throw new Error(`no row for group ${name}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("lists groups; the default group blocks rename and delete client-side", async () => {
|
||||
await renderGroupsPage(BASE);
|
||||
|
||||
const defaultRow = groupRow("default");
|
||||
expect((within(defaultRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(defaultRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(within(defaultRow).getByText("The default group cannot be renamed or deleted.")).toBeTruthy();
|
||||
|
||||
const kidsRow = groupRow("kids");
|
||||
expect((within(kidsRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(kidsRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(kidsRow).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
test("expanding sources loads the set, toggling saves the full set via PUT", async () => {
|
||||
await renderGroupsPage(BASE);
|
||||
|
||||
const kidsRow = groupRow("kids");
|
||||
fireEvent.click(within(kidsRow).getByRole("button", { name: "Sources" }));
|
||||
|
||||
const ads = (await within(kidsRow).findByRole("checkbox", { name: "Ads" })) as HTMLInputElement;
|
||||
const malware = within(kidsRow).getByRole("checkbox", { name: "Malware" }) as HTMLInputElement;
|
||||
expect(ads.checked).toBe(true);
|
||||
expect(malware.checked).toBe(false);
|
||||
|
||||
const save = within(kidsRow).getByRole("button", { name: "Save sources" }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.click(malware);
|
||||
expect(save.disabled).toBe(false);
|
||||
fireEvent.click(save);
|
||||
|
||||
await waitFor(() => expect(save.disabled).toBe(true));
|
||||
const calls = (fetch as unknown as Mock).mock.calls as [RequestInfo | URL, RequestInit | undefined][];
|
||||
const put = calls.find(([, init]) => init?.method === "PUT");
|
||||
expect(put).toBeTruthy();
|
||||
expect(String(put![0])).toBe("/api/groups/2/sources");
|
||||
expect(JSON.parse(String(put![1]?.body))).toEqual({ source_ids: [1, 2] });
|
||||
expect(malware.checked).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import {
|
||||
blocklistsQuery,
|
||||
groupCreateMutation,
|
||||
groupDeleteMutation,
|
||||
groupsQuery,
|
||||
groupUpdateMutation,
|
||||
} from "@/lib/queries";
|
||||
import type { Blocklist, Group } from "@/lib/types";
|
||||
import GroupSourcesEditor from "./GroupSourcesEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
createForm: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
list: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
marginTop: "1.5rem",
|
||||
},
|
||||
row: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
padding: "1rem",
|
||||
},
|
||||
rowControls: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
renameForm: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
name: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
actions: {
|
||||
marginLeft: "auto",
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
groupButton: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
destructive: {
|
||||
color: colors.danger,
|
||||
},
|
||||
lockNote: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export default function GroupsPage() {
|
||||
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
|
||||
const queryClient = useQueryClient();
|
||||
const createMutation = useMutation(groupCreateMutation(queryClient));
|
||||
const [newName, setNewName] = useState("");
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Groups</h1>
|
||||
<form
|
||||
{...stylex.props(styles.createForm)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (name === "") return;
|
||||
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
|
||||
}}
|
||||
>
|
||||
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
|
||||
New group
|
||||
</label>
|
||||
<input
|
||||
id="new-group-name"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
disabled={readOnly}
|
||||
{...stylex.props(shared.smallInput, shared.focusRing)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
<InlineError error={createMutation.error} />
|
||||
<ul {...stylex.props(styles.list)}>
|
||||
{groups.map((group) => (
|
||||
<GroupRow key={group.id} group={group} blocklists={blocklists} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[] }) {
|
||||
const queryClient = useQueryClient();
|
||||
const updateMutation = useMutation(groupUpdateMutation(queryClient));
|
||||
const deleteMutation = useMutation(groupDeleteMutation(queryClient));
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState(group.name);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isDefault = group.id === DEFAULT_GROUP_ID;
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
|
||||
|
||||
return (
|
||||
<li {...stylex.props(styles.row)}>
|
||||
<div {...stylex.props(styles.rowControls)}>
|
||||
{renaming ? (
|
||||
<form
|
||||
{...stylex.props(styles.renameForm)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === "") return;
|
||||
updateMutation.mutate(
|
||||
{ id: group.id, input: { name: trimmed, safe_search: group.safe_search } },
|
||||
{ onSuccess: () => setRenaming(false) },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`New name for ${group.name}`}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, shared.focusRing)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setName(group.name);
|
||||
setRenaming(false);
|
||||
}}
|
||||
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<span {...stylex.props(styles.name)}>{group.name}</span>
|
||||
)}
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={group.safe_search}
|
||||
disabled={updateMutation.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
id: group.id,
|
||||
input: { name: group.name, safe_search: event.target.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
Safe search
|
||||
</label>
|
||||
<span {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||
>
|
||||
Sources
|
||||
</button>
|
||||
{!renaming && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDefault || readOnly}
|
||||
title={lockNote}
|
||||
onClick={() => {
|
||||
setName(group.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{confirming ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
deleteMutation.mutate(group.id);
|
||||
}}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.groupButton,
|
||||
styles.destructive,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDefault || readOnly}
|
||||
title={lockNote}
|
||||
onClick={() => setConfirming(true)}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.groupButton,
|
||||
styles.destructive,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isDefault && <p {...stylex.props(styles.lockNote)}>{DEFAULT_GROUP_NOTE}</p>}
|
||||
<InlineError error={updateMutation.error ?? deleteMutation.error} />
|
||||
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
|
||||
test("toggleSource adds a missing id keeping ascending order", () => {
|
||||
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
|
||||
expect(toggleSource([], 5)).toEqual([5]);
|
||||
});
|
||||
|
||||
test("toggleSource removes a present id", () => {
|
||||
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
|
||||
expect(toggleSource([5], 5)).toEqual([]);
|
||||
});
|
||||
|
||||
test("toggleSource twice is a no-op set-wise", () => {
|
||||
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("sameSet compares regardless of order", () => {
|
||||
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
|
||||
expect(sameSet([], [])).toBe(true);
|
||||
expect(sameSet([1, 2], [1, 2, 3])).toBe(false);
|
||||
expect(sameSet([1, 2], [1, 4])).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export function toggleSource(ids: number[], id: number): number[] {
|
||||
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
|
||||
return [...ids, id].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function sameSet(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort((x, y) => x - y);
|
||||
const sortedB = [...b].sort((x, y) => x - y);
|
||||
return sortedA.every((value, i) => value === sortedB[i]);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { LiveQueryEvent } from "@/lib/types";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
import LiveLogPage from "./LiveLogPage";
|
||||
|
||||
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(<LiveLogPage createEventSource={createEventSource} />);
|
||||
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("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();
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
||||
import { RING_CAPACITY } from "./ringBuffer";
|
||||
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
pill: {
|
||||
borderRadius: "9999px",
|
||||
paddingInline: "0.625rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
/** Four stream states need four tints; only two of them map onto a token role. */
|
||||
pillConnecting: {
|
||||
backgroundColor: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||
color: { default: "oklch(37% 0.013 285.805)", [DARK]: "oklch(87.1% 0.006 286.286)" },
|
||||
},
|
||||
pillOpen: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(39.3% 0.095 152.535)" },
|
||||
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(92.5% 0.084 155.995)" },
|
||||
},
|
||||
pillRetrying: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(92.4% 0.12 95.746)" },
|
||||
},
|
||||
pillCapped: {
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||
},
|
||||
note: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** Informational, neither a warning nor a failure, so the blue ramp stands alone. */
|
||||
resumed: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
|
||||
backgroundColor: { default: "oklch(97% 0.014 254.604)", [DARK]: "oklch(28.2% 0.091 267.935)" },
|
||||
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(88.2% 0.059 254.128)" },
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
dismiss: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
color: "inherit",
|
||||
fontSize: "inherit",
|
||||
fontWeight: 500,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
failureNote: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
},
|
||||
cappedBox: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.dangerBorder,
|
||||
backgroundColor: colors.dangerSurface,
|
||||
padding: "1rem",
|
||||
},
|
||||
cappedHeading: {
|
||||
fontWeight: 600,
|
||||
color: colors.dangerText,
|
||||
},
|
||||
cappedDetail: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||
row: {
|
||||
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
rowBlocked: {
|
||||
backgroundColor: {
|
||||
default: "oklch(97.1% 0.013 17.38)",
|
||||
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
|
||||
},
|
||||
},
|
||||
footnote: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
const PILL_LABELS: Record<StreamStatus, string> = {
|
||||
connecting: "Connecting…",
|
||||
open: "Live",
|
||||
retrying: "Reconnecting…",
|
||||
capped: "Disconnected",
|
||||
};
|
||||
|
||||
function pillStyle(status: StreamStatus) {
|
||||
if (status === "open") return styles.pillOpen;
|
||||
if (status === "retrying") return styles.pillRetrying;
|
||||
if (status === "capped") return styles.pillCapped;
|
||||
return styles.pillConnecting;
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: StreamStatus }) {
|
||||
const label = PILL_LABELS[status];
|
||||
return (
|
||||
<span role="status" aria-label={label} {...stylex.props(styles.pill, pillStyle(status))}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Freeze is display-only: the stream stays open and the 500-row ring buffer keeps
|
||||
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
|
||||
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
||||
const live = useLiveQueries({ createEventSource });
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Live</h1>
|
||||
<StatusPill status={live.status} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={live.toggleFreeze}
|
||||
aria-pressed={live.frozen}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
{live.frozen ? "Resume" : "Freeze"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{live.frozen && (
|
||||
<p {...stylex.props(styles.note)} role="status">
|
||||
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
|
||||
kept).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{live.missed !== null && (
|
||||
<div role="status" {...stylex.props(styles.resumed)}>
|
||||
<span>
|
||||
Stream resumed —{" "}
|
||||
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={live.dismissMissed}
|
||||
{...stylex.props(styles.dismiss, shared.focusRing)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{live.resyncFailed && (
|
||||
<p role="alert" {...stylex.props(styles.failureNote)}>
|
||||
Stream resumed, but re-syncing the gap failed — some queries may be missing here.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{live.status === "capped" && (
|
||||
<div role="alert" {...stylex.props(styles.cappedBox)}>
|
||||
<h2 {...stylex.props(styles.cappedHeading)}>Live stream unavailable</h2>
|
||||
<p {...stylex.props(styles.cappedDetail)}>
|
||||
The connection failed repeatedly — possibly too many live viewers (the server caps streams per
|
||||
address), or the server is unreachable.
|
||||
</p>
|
||||
<button type="button" onClick={live.retry} {...stylex.props(shared.retryButton, shared.focusRing)}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{live.rows.length === 0 ? (
|
||||
live.status !== "capped" && (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<QueryTableHead />
|
||||
<tbody>
|
||||
{live.rows.map((row) => (
|
||||
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
|
||||
<QueryCells row={row} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footnote)}>
|
||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
||||
{RING_CAPACITY} kept).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { EVENT_SOURCE_CLOSED, type EventSourceLike } from "./useLiveQueries";
|
||||
|
||||
const CONNECTING = 0;
|
||||
const OPEN = 1;
|
||||
|
||||
/** Test double for the injected EventSource constructor. */
|
||||
export class FakeEventSource implements EventSourceLike {
|
||||
readonly url: string;
|
||||
closed = false;
|
||||
readyState: number = CONNECTING;
|
||||
private listeners = new Map<string, Array<(event: { data?: unknown }) => void>>();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void {
|
||||
const existing = this.listeners.get(type) ?? [];
|
||||
existing.push(listener);
|
||||
this.listeners.set(type, existing);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.readyState = EVENT_SOURCE_CLOSED;
|
||||
}
|
||||
|
||||
emit(type: string, event: { data?: unknown } = {}): void {
|
||||
if (type === "open") this.readyState = OPEN;
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-200 response: the browser closes the source, then dispatches one
|
||||
* error event and never retries.
|
||||
*/
|
||||
failFatal(): void {
|
||||
this.readyState = EVENT_SOURCE_CLOSED;
|
||||
this.emit("error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
|
||||
import { RING_CAPACITY, mergeGap, pushRow, type LiveRow } from "./ringBuffer";
|
||||
|
||||
function event(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): LiveQueryEvent {
|
||||
return {
|
||||
ts,
|
||||
domain,
|
||||
client_ip: "192.0.2.10",
|
||||
qtype: 1,
|
||||
blocked: false,
|
||||
block_reason: "",
|
||||
response_time_us: 500,
|
||||
cache_hit: false,
|
||||
upstream: "udp://9.9.9.9:53",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function liveRow(key: number, ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): LiveRow {
|
||||
return { ...event(ts, domain, overrides), key };
|
||||
}
|
||||
|
||||
function fetchedRow(id: number, ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): QueryRow {
|
||||
return { id, ...event(ts, domain, overrides) };
|
||||
}
|
||||
|
||||
function counter(start = 100): () => number {
|
||||
let n = start;
|
||||
return () => ++n;
|
||||
}
|
||||
|
||||
describe("pushRow", () => {
|
||||
test("prepends newest-first", () => {
|
||||
let rows: LiveRow[] = [];
|
||||
rows = pushRow(rows, liveRow(1, 10, "a.example"));
|
||||
rows = pushRow(rows, liveRow(2, 11, "b.example"));
|
||||
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
|
||||
});
|
||||
|
||||
test("drops the oldest beyond capacity", () => {
|
||||
let rows: LiveRow[] = [];
|
||||
for (let i = 0; i < 5; i++) rows = pushRow(rows, liveRow(i, i, `d${i}.example`), 3);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.key)).toEqual([4, 3, 2]);
|
||||
});
|
||||
|
||||
test("default capacity is 500", () => {
|
||||
let rows: LiveRow[] = [];
|
||||
for (let i = 0; i < RING_CAPACITY + 10; i++) rows = pushRow(rows, liveRow(i, i, "x.example"));
|
||||
expect(rows).toHaveLength(RING_CAPACITY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeGap", () => {
|
||||
test("skips rows already in the buffer and counts only new ones", () => {
|
||||
const buffer = [liveRow(2, 100, "seen.example"), liveRow(1, 99, "old.example")];
|
||||
const fetched = [
|
||||
fetchedRow(30, 102, "gap2.example"),
|
||||
fetchedRow(29, 101, "gap1.example"),
|
||||
fetchedRow(28, 100, "seen.example"),
|
||||
];
|
||||
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||
expect(missed).toBe(2);
|
||||
expect(rows.map((r) => r.domain)).toEqual(["gap2.example", "gap1.example", "seen.example", "old.example"]);
|
||||
});
|
||||
|
||||
test("no additions returns the buffer unchanged with missed 0", () => {
|
||||
const buffer = [liveRow(1, 100, "seen.example")];
|
||||
const { rows, missed } = mergeGap(buffer, [fetchedRow(5, 100, "seen.example")], counter());
|
||||
expect(missed).toBe(0);
|
||||
expect(rows).toBe(buffer);
|
||||
});
|
||||
|
||||
test("rows differing only in qtype are not deduplicated", () => {
|
||||
const buffer = [liveRow(1, 100, "dual.example", { qtype: 1 })];
|
||||
const fetched = [fetchedRow(5, 100, "dual.example", { qtype: 28 })];
|
||||
const { missed } = mergeGap(buffer, fetched, counter());
|
||||
expect(missed).toBe(1);
|
||||
});
|
||||
|
||||
test("assigns fresh keys from the counter and drops the id", () => {
|
||||
const { rows } = mergeGap([], [fetchedRow(77, 100, "gap.example")], counter(200));
|
||||
expect(rows[0]?.key).toBe(201);
|
||||
expect("id" in (rows[0] ?? {})).toBe(false);
|
||||
});
|
||||
|
||||
test("result is capped at capacity, keeping the newest", () => {
|
||||
const buffer = [liveRow(3, 300, "live.example")];
|
||||
const fetched = [fetchedRow(2, 302, "g2.example"), fetchedRow(1, 301, "g1.example")];
|
||||
const { rows, missed } = mergeGap(buffer, fetched, counter(), 2);
|
||||
expect(missed).toBe(2);
|
||||
expect(rows.map((r) => r.domain)).toEqual(["g2.example", "g1.example"]);
|
||||
});
|
||||
|
||||
test("merged rows stay sorted newest-first by ts", () => {
|
||||
const buffer = [liveRow(4, 105, "after-reopen.example"), liveRow(3, 100, "before.example")];
|
||||
const fetched = [fetchedRow(9, 103, "gap.example")];
|
||||
const { rows } = mergeGap(buffer, fetched, counter());
|
||||
expect(rows.map((r) => r.ts)).toEqual([105, 103, 100]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
|
||||
|
||||
/** A live stream row; `key` is a client-side monotonic counter (SSE frames carry no id). */
|
||||
export interface LiveRow extends LiveQueryEvent {
|
||||
key: number;
|
||||
}
|
||||
|
||||
export const RING_CAPACITY = 500;
|
||||
|
||||
/** Prepend `row` (rows are newest-first) and drop the oldest beyond `capacity`. */
|
||||
export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_CAPACITY): LiveRow[] {
|
||||
const next = [row, ...rows];
|
||||
return next.length > capacity ? next.slice(0, capacity) : next;
|
||||
}
|
||||
|
||||
// `since` on GET /api/queries is inclusive, so the re-sync fetch returns the
|
||||
// last-seen row(s) again; live rows have no id, so identity is this tuple.
|
||||
function signature(row: LiveQueryEvent): string {
|
||||
return `${row.ts}|${row.domain}|${row.client_ip}|${row.qtype ?? -1}|${row.blocked}|${row.upstream}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge rows fetched for a reconnect gap (newest-first, from GET /api/queries)
|
||||
* into the buffer. Rows already present are skipped; `missed` counts what was
|
||||
* actually added. The result stays newest-first (stable sort by ts) and capped.
|
||||
*/
|
||||
export function mergeGap(
|
||||
rows: LiveRow[],
|
||||
fetched: QueryRow[],
|
||||
nextKey: () => number,
|
||||
capacity: number = RING_CAPACITY,
|
||||
): { rows: LiveRow[]; missed: number } {
|
||||
const seen = new Set(rows.map(signature));
|
||||
const added: LiveRow[] = [];
|
||||
for (const row of fetched) {
|
||||
const event: LiveQueryEvent = {
|
||||
ts: row.ts,
|
||||
domain: row.domain,
|
||||
client_ip: row.client_ip,
|
||||
qtype: row.qtype,
|
||||
blocked: row.blocked,
|
||||
block_reason: row.block_reason,
|
||||
response_time_us: row.response_time_us,
|
||||
cache_hit: row.cache_hit,
|
||||
upstream: row.upstream,
|
||||
};
|
||||
if (seen.has(signature(event))) continue;
|
||||
added.push({ ...event, key: nextKey() });
|
||||
}
|
||||
if (added.length === 0) return { rows, missed: 0 };
|
||||
const merged = [...added, ...rows].sort((a, b) => b.ts - a.ts).slice(0, capacity);
|
||||
return { rows: merged, missed: added.length };
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { LiveQueryEvent, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubLocationAssign() {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/live", search: "", assign });
|
||||
return assign;
|
||||
}
|
||||
|
||||
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: false,
|
||||
upstream: "udp://9.9.9.9:53",
|
||||
...overrides,
|
||||
};
|
||||
return { data: JSON.stringify(payload) };
|
||||
}
|
||||
|
||||
function fetchedRow(id: number, ts: number, domain: string): QueryRow {
|
||||
return {
|
||||
id,
|
||||
ts,
|
||||
domain,
|
||||
client_ip: "192.0.2.10",
|
||||
qtype: 1,
|
||||
blocked: false,
|
||||
block_reason: "",
|
||||
response_time_us: 500,
|
||||
cache_hit: false,
|
||||
upstream: "udp://9.9.9.9:53",
|
||||
};
|
||||
}
|
||||
|
||||
function setup(fetchSince?: (since: number) => Promise<QueriesPage>, probeSession?: () => Promise<unknown>) {
|
||||
const sources: FakeEventSource[] = [];
|
||||
const createEventSource = (url: string) => {
|
||||
const es = new FakeEventSource(url);
|
||||
sources.push(es);
|
||||
return es;
|
||||
};
|
||||
const probe = probeSession ?? (() => Promise.resolve());
|
||||
const hook = renderHook(() => useLiveQueries({ createEventSource, fetchSince, probeSession: probe }));
|
||||
return { sources, hook };
|
||||
}
|
||||
|
||||
test("open then frames: rows newest-first with increasing keys", () => {
|
||||
const { sources, hook } = setup();
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(hook.result.current.status).toBe("connecting");
|
||||
|
||||
act(() => sources[0]!.emit("open"));
|
||||
expect(hook.result.current.status).toBe("open");
|
||||
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "a.example"));
|
||||
sources[0]!.emit("query", frame(1001, "b.example"));
|
||||
});
|
||||
const rows = hook.result.current.rows;
|
||||
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
|
||||
expect(rows[0]!.key).toBeGreaterThan(rows[1]!.key);
|
||||
});
|
||||
|
||||
test("malformed and non-string frames are ignored", () => {
|
||||
const { sources, hook } = setup();
|
||||
act(() => {
|
||||
sources[0]!.emit("open");
|
||||
sources[0]!.emit("query", { data: "{not json" });
|
||||
sources[0]!.emit("query", {});
|
||||
});
|
||||
expect(hook.result.current.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("error then reopen re-syncs the gap since the last seen ts", async () => {
|
||||
const fetchSince = vi.fn((since: number): Promise<QueriesPage> => {
|
||||
return Promise.resolve({
|
||||
queries: [fetchedRow(9, 1002, "gap.example"), fetchedRow(8, since, "a.example")],
|
||||
next_before: null,
|
||||
});
|
||||
});
|
||||
const { sources, hook } = setup(fetchSince);
|
||||
|
||||
act(() => sources[0]!.emit("open"));
|
||||
expect(fetchSince).not.toHaveBeenCalled();
|
||||
|
||||
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||
act(() => sources[0]!.emit("error"));
|
||||
expect(hook.result.current.status).toBe("retrying");
|
||||
|
||||
act(() => sources[0]!.emit("open"));
|
||||
expect(hook.result.current.status).toBe("open");
|
||||
expect(fetchSince).toHaveBeenCalledWith(1000);
|
||||
|
||||
await waitFor(() => expect(hook.result.current.missed).toBe(1));
|
||||
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["gap.example", "a.example"]);
|
||||
|
||||
act(() => hook.result.current.dismissMissed());
|
||||
expect(hook.result.current.missed).toBeNull();
|
||||
});
|
||||
|
||||
test("failed re-sync sets resyncFailed", async () => {
|
||||
const fetchSince = vi.fn((): Promise<QueriesPage> => Promise.reject(new Error("boom")));
|
||||
const { sources, hook } = setup(fetchSince);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||
act(() => sources[0]!.emit("error"));
|
||||
act(() => sources[0]!.emit("open"));
|
||||
await waitFor(() => expect(hook.result.current.resyncFailed).toBe(true));
|
||||
});
|
||||
|
||||
test("a 401 gap re-sync redirects to login instead of setting resyncFailed", async () => {
|
||||
const assign = stubLocationAssign();
|
||||
const fetchSince = vi.fn((): Promise<QueriesPage> => Promise.reject(new ApiError(401, "unauthorized")));
|
||||
const { sources, hook } = setup(fetchSince);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||
act(() => sources[0]!.emit("error"));
|
||||
act(() => sources[0]!.emit("open"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
expect(hook.result.current.resyncFailed).toBe(false);
|
||||
});
|
||||
|
||||
test("cap trip with a valid session probes once and stays capped", async () => {
|
||||
const assign = stubLocationAssign();
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
|
||||
const { sources, hook } = setup(undefined, probeSession);
|
||||
act(() => {
|
||||
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||
});
|
||||
expect(hook.result.current.status).toBe("capped");
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {});
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
expect(hook.result.current.status).toBe("capped");
|
||||
});
|
||||
|
||||
test("cap trip with an expired session redirects to login", async () => {
|
||||
const assign = stubLocationAssign();
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.reject(new ApiError(401, "unauthorized")));
|
||||
const { sources } = setup(undefined, probeSession);
|
||||
act(() => {
|
||||
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||
});
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("repeated errors without open hit the cap state; retry reconnects", () => {
|
||||
const { sources, hook } = setup();
|
||||
act(() => {
|
||||
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||
});
|
||||
expect(hook.result.current.status).toBe("capped");
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
|
||||
act(() => hook.result.current.retry());
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(hook.result.current.status).toBe("connecting");
|
||||
|
||||
act(() => sources[1]!.emit("open"));
|
||||
expect(hook.result.current.status).toBe("open");
|
||||
});
|
||||
|
||||
test("a fatal rejection caps on the first error event and probes the session", async () => {
|
||||
const assign = stubLocationAssign();
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
|
||||
const { sources, hook } = setup(undefined, probeSession);
|
||||
|
||||
act(() => sources[0]!.failFatal());
|
||||
|
||||
expect(hook.result.current.status).toBe("capped");
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
await act(async () => {});
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a fatal rejection with an expired session redirects to login", async () => {
|
||||
const assign = stubLocationAssign();
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.reject(new ApiError(401, "unauthorized")));
|
||||
const { sources } = setup(undefined, probeSession);
|
||||
|
||||
act(() => sources[0]!.failFatal());
|
||||
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a transient error leaves the source open and still takes three to cap", () => {
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
|
||||
const { sources, hook } = setup(undefined, probeSession);
|
||||
|
||||
for (let i = 0; i < CAP_ERROR_THRESHOLD - 1; i++) {
|
||||
act(() => sources[0]!.emit("error"));
|
||||
expect(hook.result.current.status).toBe("retrying");
|
||||
expect(sources[0]!.closed).toBe(false);
|
||||
expect(probeSession).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
act(() => sources[0]!.emit("error"));
|
||||
expect(hook.result.current.status).toBe("capped");
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a successful open resets the consecutive error count", () => {
|
||||
const { sources, hook } = setup();
|
||||
act(() => sources[0]!.emit("error"));
|
||||
act(() => sources[0]!.emit("error"));
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("error"));
|
||||
expect(hook.result.current.status).toBe("retrying");
|
||||
expect(sources[0]!.closed).toBe(false);
|
||||
});
|
||||
|
||||
test("freeze keeps the display fixed while the buffer keeps filling", () => {
|
||||
const { sources, hook } = setup();
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||
|
||||
act(() => hook.result.current.toggleFreeze());
|
||||
expect(hook.result.current.frozen).toBe(true);
|
||||
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1001, "b.example"));
|
||||
sources[0]!.emit("query", frame(1002, "c.example"));
|
||||
});
|
||||
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["a.example"]);
|
||||
expect(hook.result.current.liveCount).toBe(3);
|
||||
|
||||
act(() => hook.result.current.toggleFreeze());
|
||||
expect(hook.result.current.frozen).toBe(false);
|
||||
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["c.example", "b.example", "a.example"]);
|
||||
});
|
||||
|
||||
test("stale sources are ignored after retry and closed on unmount", () => {
|
||||
const { sources, hook } = setup();
|
||||
act(() => hook.result.current.retry());
|
||||
act(() => sources[0]!.emit("query", frame(1000, "stale.example")));
|
||||
expect(hook.result.current.rows).toHaveLength(0);
|
||||
|
||||
hook.unmount();
|
||||
expect(sources[1]!.closed).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as api from "@/lib/api";
|
||||
import { handleUnauthorized } from "@/lib/queryClient";
|
||||
import type { LiveQueryEvent, QueriesPage } from "@/lib/types";
|
||||
import { RING_CAPACITY, mergeGap, pushRow, type LiveRow } from "./ringBuffer";
|
||||
|
||||
export type StreamStatus = "connecting" | "open" | "retrying" | "capped";
|
||||
|
||||
/** Minimal EventSource surface so tests can inject a fake. */
|
||||
export interface EventSourceLike {
|
||||
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void;
|
||||
close(): void;
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
/** `EventSource.CLOSED`: the browser gave up and will not retry. */
|
||||
export const EVENT_SOURCE_CLOSED = 2;
|
||||
|
||||
export type EventSourceFactory = (url: string) => EventSourceLike;
|
||||
|
||||
export interface LiveQueriesOptions {
|
||||
url?: string;
|
||||
createEventSource?: EventSourceFactory;
|
||||
fetchSince?: (since: number) => Promise<QueriesPage>;
|
||||
/** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */
|
||||
probeSession?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
// A transient drop is invisible to EventSource beyond a bare `error` event;
|
||||
// this many consecutive errors without an intervening `open` (the browser
|
||||
// retries every 3s per the server's `retry: 3000`) stops the stream and
|
||||
// surfaces a manual-retry state. A non-200 response instead fails the source
|
||||
// permanently after one error event, and is handled by readyState below.
|
||||
export const CAP_ERROR_THRESHOLD = 3;
|
||||
|
||||
const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
|
||||
const defaultFetchSince = (since: number): Promise<QueriesPage> => api.getQueries({ since, limit: RING_CAPACITY });
|
||||
const defaultProbeSession = (): Promise<unknown> => api.getPause();
|
||||
|
||||
function isUnauthorized(error: unknown): boolean {
|
||||
return error instanceof api.ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
export interface LiveQueries {
|
||||
/** Newest-first; the freeze-time snapshot while frozen. */
|
||||
rows: LiveRow[];
|
||||
/** Size of the live buffer, which keeps filling while frozen. */
|
||||
liveCount: number;
|
||||
status: StreamStatus;
|
||||
/** Rows recovered by the reconnect re-sync; null until a re-sync happens or after dismissal. */
|
||||
missed: number | null;
|
||||
resyncFailed: boolean;
|
||||
frozen: boolean;
|
||||
toggleFreeze: () => void;
|
||||
retry: () => void;
|
||||
dismissMissed: () => void;
|
||||
}
|
||||
|
||||
export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
||||
const [rows, setRows] = useState<LiveRow[]>([]);
|
||||
const [status, setStatus] = useState<StreamStatus>("connecting");
|
||||
const [missed, setMissed] = useState<number | null>(null);
|
||||
const [resyncFailed, setResyncFailed] = useState(false);
|
||||
const [frozen, setFrozen] = useState(false);
|
||||
const [frozenRows, setFrozenRows] = useState<LiveRow[]>([]);
|
||||
|
||||
const bufferRef = useRef<LiveRow[]>([]);
|
||||
const keyRef = useRef(0);
|
||||
const lastSeenTsRef = useRef<number | null>(null);
|
||||
const everOpenRef = useRef(false);
|
||||
const errorsRef = useRef(0);
|
||||
const esRef = useRef<EventSourceLike | null>(null);
|
||||
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const connect = useCallback(() => {
|
||||
esRef.current?.close();
|
||||
errorsRef.current = 0;
|
||||
setStatus("connecting");
|
||||
const opts = optionsRef.current;
|
||||
const fetchSince = opts?.fetchSince ?? defaultFetchSince;
|
||||
const probeSession = opts?.probeSession ?? defaultProbeSession;
|
||||
const es = (opts?.createEventSource ?? defaultEventSource)(opts?.url ?? api.liveQueriesUrl);
|
||||
esRef.current = es;
|
||||
|
||||
es.addEventListener("open", () => {
|
||||
if (esRef.current !== es) return;
|
||||
errorsRef.current = 0;
|
||||
setStatus("open");
|
||||
const since = lastSeenTsRef.current;
|
||||
if (everOpenRef.current && since !== null) {
|
||||
setResyncFailed(false);
|
||||
fetchSince(since).then(
|
||||
(page) => {
|
||||
if (esRef.current !== es) return;
|
||||
const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current);
|
||||
bufferRef.current = merged.rows;
|
||||
setRows(merged.rows);
|
||||
setMissed(merged.missed);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (esRef.current !== es) return;
|
||||
if (isUnauthorized(error)) {
|
||||
handleUnauthorized(error);
|
||||
return;
|
||||
}
|
||||
setResyncFailed(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
everOpenRef.current = true;
|
||||
});
|
||||
|
||||
es.addEventListener("query", (event) => {
|
||||
if (esRef.current !== es) return;
|
||||
if (typeof event.data !== "string") return;
|
||||
let payload: LiveQueryEvent;
|
||||
try {
|
||||
payload = JSON.parse(event.data) as LiveQueryEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
lastSeenTsRef.current = payload.ts;
|
||||
bufferRef.current = pushRow(bufferRef.current, { ...payload, key: ++keyRef.current });
|
||||
setRows(bufferRef.current);
|
||||
});
|
||||
|
||||
const giveUp = () => {
|
||||
es.close();
|
||||
setStatus("capped");
|
||||
// EventSource cannot surface a 401; an expired session looks
|
||||
// identical to the cap. Probe once on entering capped so the
|
||||
// user lands on login instead of a misleading capped message.
|
||||
probeSession().catch(handleUnauthorized);
|
||||
};
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
if (esRef.current !== es) return;
|
||||
// A 429 or 401 closes the source outright — no retry follows, so
|
||||
// the consecutive-error counter would never reach its threshold.
|
||||
if (es.readyState === EVENT_SOURCE_CLOSED) {
|
||||
errorsRef.current = CAP_ERROR_THRESHOLD;
|
||||
giveUp();
|
||||
return;
|
||||
}
|
||||
errorsRef.current += 1;
|
||||
if (errorsRef.current >= CAP_ERROR_THRESHOLD) {
|
||||
giveUp();
|
||||
} else {
|
||||
setStatus("retrying");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const toggleFreeze = () => {
|
||||
if (frozen) {
|
||||
setFrozen(false);
|
||||
} else {
|
||||
setFrozen(true);
|
||||
setFrozenRows(bufferRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
rows: frozen ? frozenRows : rows,
|
||||
liveCount: rows.length,
|
||||
status,
|
||||
missed,
|
||||
resyncFailed,
|
||||
frozen,
|
||||
toggleFreeze,
|
||||
retry: connect,
|
||||
dismissMissed: () => {
|
||||
setMissed(null);
|
||||
setResyncFailed(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { LocalRecord, LocalRecordInput } from "@/lib/types";
|
||||
|
||||
let records: LocalRecord[];
|
||||
let fetchMock: ReturnType<typeof createFetchMock>;
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? "GET";
|
||||
if (url === "/api/local-records" && method === "GET") return json({ local_records: records });
|
||||
if (url === "/api/local-records" && method === "POST") {
|
||||
const body = JSON.parse(String(init?.body)) as LocalRecordInput;
|
||||
const created: LocalRecord = { id: 99, ttl: body.ttl ?? 300, ...body };
|
||||
records = [...records, created];
|
||||
return json(created, 201);
|
||||
}
|
||||
if (url.startsWith("/api/local-records/") && method === "DELETE") {
|
||||
const id = Number(url.slice("/api/local-records/".length));
|
||||
records = records.filter((record) => record.id !== id);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.startsWith("/api/forward-zones/") && method === "DELETE") return new Response(null, { status: 204 });
|
||||
if (url === "/api/forward-zones" && method === "GET") {
|
||||
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
|
||||
}
|
||||
return json({ error: "not stubbed" }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
records = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.10", ttl: 300 }];
|
||||
fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/local-dns"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
test("renders the records table and switches to the forward zones tab", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByRole("heading", { name: "Local DNS" });
|
||||
await screen.findByText("nas.lan.home");
|
||||
expect(screen.getByText("192.168.1.10")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
|
||||
await screen.findByText("lan.home");
|
||||
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the arrow keys move between tabs", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("nas.lan.home");
|
||||
|
||||
const tablist = screen.getByRole("tablist", { name: "Local DNS" });
|
||||
const records = screen.getByRole("tab", { name: "Records" });
|
||||
expect(records.getAttribute("aria-selected")).toBe("true");
|
||||
|
||||
fireEvent.keyDown(tablist, { key: "ArrowRight" });
|
||||
const zones = screen.getByRole("tab", { name: "Forward zones" });
|
||||
expect(zones.getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("false");
|
||||
await screen.findByText("lan.home");
|
||||
|
||||
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
|
||||
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true");
|
||||
await screen.findByText("nas.lan.home");
|
||||
});
|
||||
|
||||
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("nas.lan.home");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
|
||||
// The record type is a RAC Select now: open the listbox, then pick.
|
||||
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
|
||||
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await screen.findByText("printer.lan.home");
|
||||
|
||||
const post = fetchMock.mock.calls.find(
|
||||
([input, init]) => init?.method === "POST" && String(input) === "/api/local-records",
|
||||
);
|
||||
expect(post).toBeTruthy();
|
||||
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
|
||||
});
|
||||
|
||||
test("cancelling the record delete dialog sends no request", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("nas.lan.home");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
|
||||
expect(screen.getByText("nas.lan.home")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("confirming the record delete dialog issues the DELETE", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("nas.lan.home");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await screen.findByRole("alertdialog");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) => init?.method === "DELETE" && String(input) === "/api/local-records/1",
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText("nas.lan.home")).toBeNull());
|
||||
});
|
||||
|
||||
test("the forward zone delete dialog names the zone and confirms", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("nas.lan.home");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
|
||||
await screen.findByText("lan.home");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await screen.findByRole("alertdialog");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([input, init]) => init?.method === "DELETE" && String(input) === "/api/forward-zones/7",
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import RecordsTab from "@/features/local/RecordsTab";
|
||||
import ZonesTab from "@/features/local/ZonesTab";
|
||||
import Tabs from "@/ui/Tabs";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
});
|
||||
|
||||
export default function LocalDnsPage() {
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Local DNS</h1>
|
||||
<Tabs
|
||||
label="Local DNS"
|
||||
tabs={[
|
||||
{ id: "records", label: "Records", content: <RecordsTab /> },
|
||||
{ id: "zones", label: "Forward zones", content: <ZonesTab /> },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import {
|
||||
localRecordCreateMutation,
|
||||
localRecordDeleteMutation,
|
||||
localRecordUpdateMutation,
|
||||
localRecordsQuery,
|
||||
} from "@/lib/queries";
|
||||
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { useCrudForm } from "@/ui/useCrudForm";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
||||
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
|
||||
|
||||
const styles = stylex.create({
|
||||
formHeading: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
intro: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
headCell: {
|
||||
paddingBlock: "0.5rem",
|
||||
paddingRight: "1rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
headCellLast: {
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
bodyRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingBlock: "0.5rem",
|
||||
paddingRight: "1rem",
|
||||
},
|
||||
emptyCell: {
|
||||
paddingBlock: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
actionCell: {
|
||||
paddingBlock: "0.5rem",
|
||||
textAlign: "right",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
});
|
||||
|
||||
function RecordForm({
|
||||
initial,
|
||||
busy,
|
||||
readOnly,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: LocalRecord;
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
error: unknown;
|
||||
onSubmit: (input: LocalRecordInput) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [rtype, setRtype] = useState<LocalRecordType>(initial?.rtype ?? "A");
|
||||
const [value, setValue] = useState(initial?.value ?? "");
|
||||
const [ttl, setTtl] = useState(initial === undefined ? "" : String(initial.ttl));
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const input: LocalRecordInput = { name: name.trim(), rtype, value: value.trim() };
|
||||
if (ttl.trim() !== "") input.ttl = Number(ttl);
|
||||
onSubmit(input);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
|
||||
<h3 {...stylex.props(styles.formHeading)}>
|
||||
{initial === undefined ? "New record" : `Edit ${initial.name}`}
|
||||
</h3>
|
||||
<div>
|
||||
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-name`}
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="nas.lan.home"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
label="Type"
|
||||
value={rtype}
|
||||
onChange={(next) => setRtype(next as LocalRecordType)}
|
||||
options={RTYPE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-value`}
|
||||
required
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder={
|
||||
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
|
||||
}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
|
||||
TTL (seconds)
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-ttl`}
|
||||
type="number"
|
||||
min={0}
|
||||
value={ttl}
|
||||
onChange={(event) => setTtl(event.target.value)}
|
||||
placeholder="300"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={error} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecordsTab() {
|
||||
const records = useSuspenseQuery(localRecordsQuery()).data;
|
||||
const {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
form,
|
||||
openForm,
|
||||
closeForm,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
pendingDelete,
|
||||
confirmPendingDelete,
|
||||
cancelPendingDelete,
|
||||
} = useCrudForm<LocalRecord, LocalRecordInput>({
|
||||
create: localRecordCreateMutation,
|
||||
update: localRecordUpdateMutation,
|
||||
remove: localRecordDeleteMutation,
|
||||
confirmDelete: (record) => `Delete record "${record.name}"?`,
|
||||
});
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "create" })}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
Add record
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<RecordForm
|
||||
busy={create.isPending}
|
||||
readOnly={readOnly}
|
||||
error={create.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
)}
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
Type
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
Value
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
TTL
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
|
||||
No local records yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{records.map((record) => (
|
||||
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
|
||||
{form?.mode === "edit" && form.entity.id === record.id ? (
|
||||
<td colSpan={5}>
|
||||
<RecordForm
|
||||
initial={record}
|
||||
busy={update.isPending}
|
||||
readOnly={readOnly}
|
||||
error={update.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
|
||||
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
|
||||
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
|
||||
<td {...stylex.props(styles.actionCell)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "edit", entity: record })}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(
|
||||
shared.rowButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(record)}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(
|
||||
shared.rowButton,
|
||||
styles.dangerText,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete record"
|
||||
message={pendingDelete?.message ?? ""}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmPendingDelete}
|
||||
onCancel={cancelPendingDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import {
|
||||
forwardZoneCreateMutation,
|
||||
forwardZoneDeleteMutation,
|
||||
forwardZoneUpdateMutation,
|
||||
forwardZonesQuery,
|
||||
} from "@/lib/queries";
|
||||
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { useCrudForm } from "@/ui/useCrudForm";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
formHeading: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
intro: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
headCell: {
|
||||
paddingBlock: "0.5rem",
|
||||
paddingRight: "1rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
headCellLast: {
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
bodyRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingBlock: "0.5rem",
|
||||
paddingRight: "1rem",
|
||||
},
|
||||
emptyCell: {
|
||||
paddingBlock: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
actionCell: {
|
||||
paddingBlock: "0.5rem",
|
||||
textAlign: "right",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
});
|
||||
|
||||
function ZoneForm({
|
||||
initial,
|
||||
busy,
|
||||
readOnly,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: ForwardZone;
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
error: unknown;
|
||||
onSubmit: (input: ForwardZoneInput) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [zone, setZone] = useState(initial?.zone ?? "");
|
||||
const [resolver, setResolver] = useState(initial?.resolver ?? "");
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
onSubmit({ zone: zone.trim(), resolver: resolver.trim() });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
|
||||
<h3 {...stylex.props(styles.formHeading)}>
|
||||
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
|
||||
</h3>
|
||||
<div>
|
||||
<label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
|
||||
Zone
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-zone`}
|
||||
required
|
||||
value={zone}
|
||||
onChange={(event) => setZone(event.target.value)}
|
||||
placeholder="lan.home"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
|
||||
Resolver
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-resolver`}
|
||||
required
|
||||
value={resolver}
|
||||
onChange={(event) => setResolver(event.target.value)}
|
||||
placeholder="udp://192.168.1.1:53"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={error} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ZonesTab() {
|
||||
const zones = useSuspenseQuery(forwardZonesQuery()).data;
|
||||
const {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
form,
|
||||
openForm,
|
||||
closeForm,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
pendingDelete,
|
||||
confirmPendingDelete,
|
||||
cancelPendingDelete,
|
||||
} = useCrudForm<ForwardZone, ForwardZoneInput>({
|
||||
create: forwardZoneCreateMutation,
|
||||
update: forwardZoneUpdateMutation,
|
||||
remove: forwardZoneDeleteMutation,
|
||||
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
|
||||
});
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
Names under these zones go to their own resolver. Changes apply live.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "create" })}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
Add zone
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<ZoneForm
|
||||
busy={create.isPending}
|
||||
readOnly={readOnly}
|
||||
error={create.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
)}
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
Zone
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||
Resolver
|
||||
</th>
|
||||
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zones.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
|
||||
No forward zones yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{zones.map((zone) => (
|
||||
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
|
||||
{form?.mode === "edit" && form.entity.id === zone.id ? (
|
||||
<td colSpan={3}>
|
||||
<ZoneForm
|
||||
initial={zone}
|
||||
busy={update.isPending}
|
||||
readOnly={readOnly}
|
||||
error={update.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
|
||||
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
|
||||
<td {...stylex.props(styles.actionCell)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "edit", entity: zone })}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(
|
||||
shared.rowButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(zone)}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(
|
||||
shared.rowButton,
|
||||
styles.dangerText,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete forward zone"
|
||||
message={pendingDelete?.message ?? ""}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmPendingDelete}
|
||||
onCancel={cancelPendingDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { LookupResult } from "@/lib/types";
|
||||
|
||||
const BLOCKED: LookupResult = {
|
||||
domain: "ads.example",
|
||||
group_id: 1,
|
||||
local_records: false,
|
||||
forward_zone: null,
|
||||
blocked: true,
|
||||
reason: "blocklist_domain",
|
||||
matched: "ads.example",
|
||||
source_url: "https://lists.test/a",
|
||||
safe_search_rewrite: null,
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof createFetchMock>;
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/groups") {
|
||||
return json({
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === "/api/lookup?domain=ads.example&group_id=1") return json(BLOCKED);
|
||||
return json({ error: "not stubbed" }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/lookup"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function lookupCalls(): string[] {
|
||||
return fetchMock.mock.calls.map(([input]) => String(input)).filter((url) => url.startsWith("/api/lookup"));
|
||||
}
|
||||
|
||||
test("fetches nothing until submit, then renders the blocked verdict", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByRole("heading", { name: "Lookup" });
|
||||
await screen.findByLabelText("Group");
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Look up" }));
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocked" });
|
||||
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||
|
||||
expect(screen.getByText("blocklist_domain")).toBeTruthy();
|
||||
const link = screen.getByRole("link", { name: "https://lists.test/a" }) as HTMLAnchorElement;
|
||||
expect(link.href).toBe("https://lists.test/a");
|
||||
expect(screen.getByText("Queries for this name get a blocked response.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("defaults the group select to the default group (id 1)", async () => {
|
||||
renderPage();
|
||||
// A RAC Select names its trigger with the current value and then the label, so
|
||||
// the selected group's name is the only thing the trigger shows.
|
||||
const trigger = await screen.findByRole("button", { name: /Group$/ });
|
||||
expect(trigger.textContent).toContain("default");
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
||||
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";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
interface Submitted {
|
||||
domain: string;
|
||||
groupId: number;
|
||||
}
|
||||
|
||||
interface Verdict {
|
||||
label: string;
|
||||
tone: "local" | "blocked" | "forwarded" | "allowed";
|
||||
description: string;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
form: {
|
||||
marginTop: "1.5rem",
|
||||
display: "flex",
|
||||
maxWidth: "42rem",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
domainField: {
|
||||
minWidth: "14rem",
|
||||
flexGrow: 1,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
note: {
|
||||
marginTop: "1.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
error: {
|
||||
marginTop: "1.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.danger,
|
||||
},
|
||||
card: {
|
||||
marginTop: "1.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
banner: {
|
||||
borderStartStartRadius: "0.25rem",
|
||||
borderStartEndRadius: "0.25rem",
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
/** Four verdicts need four tints; only "blocked" maps onto a token role. */
|
||||
local: {
|
||||
backgroundColor: { default: "oklch(93.2% 0.032 255.585)", [DARK]: "oklch(28.2% 0.091 267.935)" },
|
||||
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(80.9% 0.105 251.813)" },
|
||||
},
|
||||
blocked: {
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
|
||||
},
|
||||
forwarded: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
|
||||
},
|
||||
allowed: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(26.6% 0.065 152.934)" },
|
||||
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(87.1% 0.15 154.449)" },
|
||||
},
|
||||
verdictLabel: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
verdictDescription: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
details: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||
detailRow: {
|
||||
display: "flex",
|
||||
gap: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
detailTerm: {
|
||||
width: "10rem",
|
||||
flexShrink: 0,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
detailValue: {
|
||||
minWidth: 0,
|
||||
overflowWrap: "break-word",
|
||||
},
|
||||
sourceLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
function toneStyle(tone: Verdict["tone"]) {
|
||||
if (tone === "local") return styles.local;
|
||||
if (tone === "blocked") return styles.blocked;
|
||||
return tone === "forwarded" ? styles.forwarded : styles.allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header priority follows the pipeline order the lookup handler documents
|
||||
* (PLAN §6): local records answer first, then the block decision, then
|
||||
* forward zones, then plain forwarding to the upstream pool.
|
||||
*/
|
||||
export function verdictOf(result: LookupResult): Verdict {
|
||||
if (result.local_records) {
|
||||
return {
|
||||
label: "Local answer",
|
||||
tone: "local",
|
||||
description: "A local record answers this name directly.",
|
||||
};
|
||||
}
|
||||
if (result.blocked) {
|
||||
return {
|
||||
label: "Blocked",
|
||||
tone: "blocked",
|
||||
description: "Queries for this name get a blocked response.",
|
||||
};
|
||||
}
|
||||
if (result.forward_zone !== null) {
|
||||
return {
|
||||
label: "Forwarded",
|
||||
tone: "forwarded",
|
||||
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Allowed",
|
||||
tone: "allowed",
|
||||
description: "Queries resolve through the upstream pool.",
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 503) {
|
||||
return "No filter snapshot is loaded yet — the server is starting or degraded. Try again shortly.";
|
||||
}
|
||||
if (error.status === 429) {
|
||||
return error.retryAfter !== undefined
|
||||
? `Rate limited. Try again in ${error.retryAfter}s.`
|
||||
: "Rate limited. Try again shortly.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return "Could not reach the server.";
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div {...stylex.props(styles.detailRow)}>
|
||||
<dt {...stylex.props(styles.detailTerm)}>{label}</dt>
|
||||
<dd {...stylex.props(styles.detailValue)}>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[] }) {
|
||||
const verdict = verdictOf(result);
|
||||
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.card)}>
|
||||
<div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
|
||||
<h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
|
||||
<p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
|
||||
</div>
|
||||
<dl {...stylex.props(styles.details)}>
|
||||
<DetailRow label="Domain">
|
||||
<span {...stylex.props(shared.mono)}>{result.domain}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Group">{groupName}</DetailRow>
|
||||
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
|
||||
<DetailRow label="Forward zone">
|
||||
{result.forward_zone !== null ? (
|
||||
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
|
||||
<DetailRow label="Reason">
|
||||
<span {...stylex.props(shared.mono)}>{result.reason}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Matched pattern">
|
||||
{result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Blocklist source">
|
||||
{result.source_url !== null ? (
|
||||
<a
|
||||
href={result.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
{...stylex.props(styles.sourceLink, shared.focusRing)}
|
||||
>
|
||||
{result.source_url}
|
||||
</a>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Safe search rewrite">
|
||||
{result.safe_search_rewrite !== null ? (
|
||||
<span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LookupPage() {
|
||||
const groups = useSuspenseQuery(groupsQuery()).data;
|
||||
const preselectedGroupId = defaultGroupId(groups);
|
||||
|
||||
const [domain, setDomain] = useState("");
|
||||
const [groupId, setGroupId] = useState(preselectedGroupId);
|
||||
const [submitted, setSubmitted] = useState<Submitted | null>(null);
|
||||
|
||||
const lookup = useQuery({
|
||||
...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
|
||||
enabled: submitted !== null,
|
||||
});
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmed = domain.trim();
|
||||
if (trimmed === "") return;
|
||||
if (submitted !== null && submitted.domain === trimmed && submitted.groupId === groupId) {
|
||||
void lookup.refetch();
|
||||
return;
|
||||
}
|
||||
setSubmitted({ domain: trimmed, groupId });
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||
<div {...stylex.props(styles.domainField)}>
|
||||
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
id="lookup-domain"
|
||||
required
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="ads.example.com"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
label="Group"
|
||||
value={String(groupId)}
|
||||
onChange={(value) => setGroupId(Number(value))}
|
||||
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={lookup.isFetching}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
Look up
|
||||
</button>
|
||||
</form>
|
||||
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up…</p>}
|
||||
{!lookup.isFetching && lookup.isError && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{errorMessage(lookup.error)}
|
||||
</p>
|
||||
)}
|
||||
{!lookup.isFetching && lookup.data !== undefined && !lookup.isError && (
|
||||
<VerdictCard result={lookup.data} groups={groups} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { act } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import PauseWidget, { formatRemaining } from "@/features/pause/PauseWidget";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { PausePost, PauseState } from "@/lib/types";
|
||||
|
||||
let getState: PauseState;
|
||||
let postBodies: PausePost[];
|
||||
let postResponse: (body: PausePost) => PauseState;
|
||||
let postFailure: (() => Response) | null;
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
postBodies = [];
|
||||
postFailure = null;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url !== "/api/pause") return jsonResponse({ error: "not stubbed" });
|
||||
if (init?.method === "POST") {
|
||||
const body = JSON.parse(String(init.body)) as PausePost;
|
||||
postBodies.push(body);
|
||||
if (postFailure !== null) return postFailure();
|
||||
return jsonResponse(postResponse(body));
|
||||
}
|
||||
return jsonResponse(getState);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderWidget(client?: QueryClient) {
|
||||
render(
|
||||
<QueryClientProvider client={client ?? createQueryClient()}>
|
||||
<PauseWidget />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function findPauseTrigger(): Promise<HTMLButtonElement> {
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(false);
|
||||
});
|
||||
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
|
||||
renderWidget();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
|
||||
expect(screen.getByRole("button", { name: label })).toBeTruthy();
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.getByText(/^Paused \d+:\d{2}$/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("indefinite pause sends no duration_seconds and renders without a countdown", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postResponse = () => ({ paused: true, until: null });
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.getByText("Paused")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resume posts paused false and returns to the Pause button", async () => {
|
||||
getState = { paused: true, until: null };
|
||||
postResponse = () => ({ paused: false, until: null });
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
|
||||
await screen.findByRole("button", { name: "Pause" });
|
||||
});
|
||||
|
||||
test("timed pause counts down live", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const client = createQueryClient();
|
||||
client.setQueryData(queryKeys.pause, { paused: true, until: nowSec + 90 });
|
||||
renderWidget(client);
|
||||
|
||||
expect(screen.getByText("Paused 1:30")).toBeTruthy();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(screen.getByText("Paused 1:28")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("escape closes the duration menu", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postResponse = () => getState;
|
||||
renderWidget();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
fireEvent.keyDown(trigger, { key: "Escape" });
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
});
|
||||
|
||||
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
getState = { paused: false, until: null };
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "Retry-After": "30" },
|
||||
});
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
|
||||
});
|
||||
|
||||
test("failed pause with 503 shows the degraded message", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||
});
|
||||
|
||||
test("a successful pause clears the previous mutation error", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await screen.findByRole("alert");
|
||||
|
||||
postFailure = null;
|
||||
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("formatRemaining renders m:ss and h:mm:ss and clamps at zero", () => {
|
||||
expect(formatRemaining(0)).toBe("0:00");
|
||||
expect(formatRemaining(-5)).toBe("0:00");
|
||||
expect(formatRemaining(59)).toBe("0:59");
|
||||
expect(formatRemaining(90)).toBe("1:30");
|
||||
expect(formatRemaining(3661)).toBe("1:01:01");
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { pauseMutation, pauseQuery } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DURATIONS = [
|
||||
{ label: "60 seconds", seconds: 60 },
|
||||
{ label: "5 minutes", seconds: 300 },
|
||||
{ label: "30 minutes", seconds: 1800 },
|
||||
{ label: "Indefinitely", seconds: null },
|
||||
] as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* Disabled text darkens in light scheme and lightens in dark, the opposite
|
||||
* direction from `textMuted`, so the token cannot express it.
|
||||
*/
|
||||
trigger: {
|
||||
color: {
|
||||
default: null,
|
||||
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||
},
|
||||
},
|
||||
pausedRow: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
pausedControls: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
/**
|
||||
* Amber as standalone text on the app ground, not inside a warning banner, so
|
||||
* the `warn*` tokens — tuned against `warnSurface` — do not apply here.
|
||||
*/
|
||||
pausedLabel: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: {
|
||||
default: "oklch(55.5% 0.163 48.998)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
|
||||
},
|
||||
},
|
||||
anchor: {
|
||||
position: "relative",
|
||||
},
|
||||
menu: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: "100%",
|
||||
zIndex: 10,
|
||||
marginTop: "0.25rem",
|
||||
display: "flex",
|
||||
width: "9rem",
|
||||
flexDirection: "column",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingBlock: "0.25rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menuItem: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: "inherit",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
});
|
||||
|
||||
export function formatRemaining(totalSeconds: number): string {
|
||||
const clamped = Math.max(0, totalSeconds);
|
||||
const hours = Math.floor(clamped / 3600);
|
||||
const minutes = Math.floor((clamped % 3600) / 60);
|
||||
const seconds = clamped % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function useNowSeconds(active: boolean): number {
|
||||
const [now, setNow] = useState(nowSeconds);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setNow(nowSeconds());
|
||||
const id = setInterval(() => setNow(nowSeconds()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export default function PauseWidget() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useQuery({
|
||||
...pauseQuery(),
|
||||
refetchInterval: (query) => (query.state.data?.paused === true ? 5000 : false),
|
||||
});
|
||||
const mutation = useMutation(pauseMutation(queryClient));
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const now = useNowSeconds(data?.paused === true && data.until !== null);
|
||||
const paused = data?.paused === true;
|
||||
const { reset } = mutation;
|
||||
useEffect(() => reset(), [paused, reset]);
|
||||
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
|
||||
Pause
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.paused) {
|
||||
return (
|
||||
<div {...stylex.props(styles.pausedRow)}>
|
||||
<div {...stylex.props(styles.pausedControls)}>
|
||||
<span {...stylex.props(styles.pausedLabel)}>
|
||||
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mutation.mutate({ paused: false })}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...stylex.props(styles.anchor)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="pause-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
mutation.mutate(
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import type { QueriesPage, QueryRow } from "@/lib/types";
|
||||
import QueryLogPage from "./QueryLogPage";
|
||||
|
||||
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||
return {
|
||||
id,
|
||||
ts: 1_700_000_000 + id,
|
||||
domain,
|
||||
client_ip: "192.0.2.10",
|
||||
qtype: 1,
|
||||
blocked: false,
|
||||
block_reason: "",
|
||||
response_time_us: 1234,
|
||||
cache_hit: false,
|
||||
upstream: "udp://9.9.9.9:53",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const PAGES: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [
|
||||
row(20, "first.example", { qtype: 65, cache_hit: true, upstream: "" }),
|
||||
row(19, "ads.example", {
|
||||
blocked: true,
|
||||
block_reason: "blocklist:stevenblack",
|
||||
response_time_us: null,
|
||||
cache_hit: null,
|
||||
}),
|
||||
],
|
||||
next_before: 19,
|
||||
},
|
||||
"/api/queries?before=19": {
|
||||
queries: [row(5, "older.example")],
|
||||
next_before: null,
|
||||
},
|
||||
"/api/queries?domain=ads": {
|
||||
queries: [row(19, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack" })],
|
||||
next_before: null,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<QueryLogPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
test("renders the first page with type names, blocked badge, and formatted cells", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
expect(screen.getByText("HTTPS")).toBeTruthy();
|
||||
expect(screen.getByText("A")).toBeTruthy();
|
||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
|
||||
expect(screen.getByText("1.2 ms")).toBeTruthy();
|
||||
expect(screen.getByText("hit")).toBeTruthy();
|
||||
expect(screen.getByText("udp://9.9.9.9:53")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("load more appends the next page and stops at the end of the log", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
expect(screen.getByText("first.example")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
test("applying a filter refetches and resets the accumulated list", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
});
|
||||
|
||||
test("a load-more that resolves after a filter change is discarded", async () => {
|
||||
let releaseLoadMore: () => void = () => {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseLoadMore = () => {
|
||||
resolve(
|
||||
new Response(JSON.stringify(PAGES["/api/queries?before=19"]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined)
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
|
||||
let releaseFiltered: () => void = () => {};
|
||||
const filteredPage: QueriesPage = {
|
||||
queries: [row(19, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack" })],
|
||||
next_before: 7,
|
||||
};
|
||||
const filteredOlderPage: QueriesPage = {
|
||||
queries: [row(3, "ads.older.example")],
|
||||
next_before: null,
|
||||
};
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?domain=ads") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseFiltered = () => {
|
||||
resolve(
|
||||
new Response(JSON.stringify(filteredPage), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
const payload = url === "/api/queries?domain=ads&before=7" ? filteredOlderPage : PAGES[url];
|
||||
if (payload === undefined)
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
const staleButton = screen.getByRole("button", { name: "Load more" });
|
||||
expect(staleButton).toHaveProperty("disabled", true);
|
||||
fireEvent.click(staleButton);
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).not.toContain("/api/queries?domain=ads&before=19");
|
||||
|
||||
releaseFiltered();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
const freshButton = screen.getByRole("button", { name: "Load more" });
|
||||
expect(freshButton).toHaveProperty("disabled", false);
|
||||
fireEvent.click(freshButton);
|
||||
await screen.findByText("ads.older.example");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toContain("/api/queries?domain=ads&before=7");
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
// The newest-100 window moves up while the reader has a second page open.
|
||||
// Refetching only the first page would drop n20 and n19 out of the middle
|
||||
// of the table; the second page must be replayed from the fresh cursor.
|
||||
const before: Record<string, QueriesPage> = {
|
||||
"/api/queries": { queries: [row(20, "n20.example"), row(19, "n19.example")], next_before: 19 },
|
||||
"/api/queries?before=19": { queries: [row(18, "n18.example"), row(17, "n17.example")], next_before: null },
|
||||
};
|
||||
const after: Record<string, QueriesPage> = {
|
||||
"/api/queries": { queries: [row(22, "n22.example"), row(21, "n21.example")], next_before: 21 },
|
||||
"/api/queries?before=21": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
},
|
||||
};
|
||||
let live = before;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const payload = live[String(input)];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = renderPage();
|
||||
await screen.findByText("n20.example");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("n17.example");
|
||||
|
||||
live = after;
|
||||
await act(async () => {
|
||||
await client.invalidateQueries({ queryKey: ["queries"] });
|
||||
});
|
||||
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/queries", search: "", assign });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() => {
|
||||
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/queries")}`);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByText(/Failed to load more/)).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import * as api from "@/lib/api";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||
import { qtypeName } from "./qtype";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
{ value: "allowed", label: "Allowed only" },
|
||||
];
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
/** One column on a phone, two from `sm`, five from `lg`, as before. */
|
||||
filterGrid: {
|
||||
marginTop: "1rem",
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
filterLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
filterInput: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.5rem",
|
||||
gridColumn: {
|
||||
default: null,
|
||||
"@media (min-width: 640px)": "span 2 / span 2",
|
||||
"@media (min-width: 1024px)": "span 5 / span 5",
|
||||
},
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** The header tint is a shade off the ground in each scheme, not a token role. */
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
textAlign: "left",
|
||||
},
|
||||
th: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||
row: {
|
||||
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
nowrap: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
blockedWrap: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
blockedBadge: {
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||
},
|
||||
footer: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
moreError: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
},
|
||||
});
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function datetimeLocalToUnix(value: string): number | undefined {
|
||||
if (value === "") return undefined;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||
}
|
||||
|
||||
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
|
||||
if (!row.blocked) return <span {...stylex.props(styles.muted)}>—</span>;
|
||||
return (
|
||||
<span {...stylex.props(styles.blockedWrap)}>
|
||||
<span {...stylex.props(styles.blockedBadge)}>Blocked</span>
|
||||
{row.block_reason !== "" && <span {...stylex.props(styles.small, styles.muted)}>{row.block_reason}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<BlockedCell row={row} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{row.upstream === "" ? "—" : row.upstream}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueryTableHead() {
|
||||
return (
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
<th {...stylex.props(styles.th)}>Time</th>
|
||||
<th {...stylex.props(styles.th)}>Domain</th>
|
||||
<th {...stylex.props(styles.th)}>Client</th>
|
||||
<th {...stylex.props(styles.th)}>Type</th>
|
||||
<th {...stylex.props(styles.th)}>Status</th>
|
||||
<th {...stylex.props(styles.th)}>Response</th>
|
||||
<th {...stylex.props(styles.th)}>Cache</th>
|
||||
<th {...stylex.props(styles.th)}>Upstream</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueryLogPage() {
|
||||
const [domain, setDomain] = useState("");
|
||||
const [client, setClient] = useState("");
|
||||
const [blocked, setBlocked] = useState("any");
|
||||
const [since, setSince] = useState("");
|
||||
const [until, setUntil] = useState("");
|
||||
|
||||
const [applied, setApplied] = useState<QueriesFilter>({});
|
||||
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
||||
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const filterActive = Object.keys(applied).length > 0;
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
// screen so the button keeps its place instead of flashing "end of log".
|
||||
const lastPage = pages[pages.length - 1];
|
||||
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
|
||||
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
||||
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
|
||||
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
|
||||
|
||||
function applyFilters(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const filter: QueriesFilter = {};
|
||||
if (domain.trim() !== "") filter.domain = domain.trim();
|
||||
if (client.trim() !== "") filter.client = client.trim();
|
||||
if (blocked === "blocked") filter.blocked = true;
|
||||
if (blocked === "allowed") filter.blocked = false;
|
||||
const sinceTs = datetimeLocalToUnix(since);
|
||||
if (sinceTs !== undefined) filter.since = sinceTs;
|
||||
const untilTs = datetimeLocalToUnix(until);
|
||||
if (untilTs !== undefined) filter.until = untilTs;
|
||||
setApplied(filter);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setDomain("");
|
||||
setClient("");
|
||||
setBlocked("any");
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setApplied({});
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||
void base.fetchNextPage();
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Query Log</h1>
|
||||
|
||||
<form onSubmit={applyFilters} {...stylex.props(styles.filterGrid)}>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Domain contains
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Client (exact)
|
||||
<input
|
||||
type="text"
|
||||
value={client}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
variant="compactField"
|
||||
label="Status"
|
||||
value={blocked}
|
||||
onChange={setBlocked}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Since
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={since}
|
||||
onChange={(event) => setSince(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Until
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
onChange={(event) => setUntil(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button type="submit" {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}>
|
||||
Apply filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{base.isFetching && (
|
||||
<span {...stylex.props(styles.note)} role="status">
|
||||
Loading…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{base.data === undefined ? (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
Loading query log…
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<QueryTableHead />
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||
<QueryCells row={row} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moreError !== null && (
|
||||
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||
Failed to load more: {moreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { qtypeName } from "./qtype";
|
||||
|
||||
test("common qtype codes render as DNS type names", () => {
|
||||
expect(qtypeName(1)).toBe("A");
|
||||
expect(qtypeName(28)).toBe("AAAA");
|
||||
expect(qtypeName(5)).toBe("CNAME");
|
||||
expect(qtypeName(65)).toBe("HTTPS");
|
||||
expect(qtypeName(16)).toBe("TXT");
|
||||
});
|
||||
|
||||
test("unknown codes fall back to TYPE<n>", () => {
|
||||
expect(qtypeName(99)).toBe("TYPE99");
|
||||
expect(qtypeName(0)).toBe("TYPE0");
|
||||
});
|
||||
|
||||
test("null qtype renders as a dash", () => {
|
||||
expect(qtypeName(null)).toBe("—");
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const QTYPE_NAMES: Record<number, string> = {
|
||||
1: "A",
|
||||
2: "NS",
|
||||
5: "CNAME",
|
||||
6: "SOA",
|
||||
12: "PTR",
|
||||
15: "MX",
|
||||
16: "TXT",
|
||||
28: "AAAA",
|
||||
33: "SRV",
|
||||
35: "NAPTR",
|
||||
43: "DS",
|
||||
46: "RRSIG",
|
||||
47: "NSEC",
|
||||
48: "DNSKEY",
|
||||
52: "TLSA",
|
||||
64: "SVCB",
|
||||
65: "HTTPS",
|
||||
255: "ANY",
|
||||
257: "CAA",
|
||||
};
|
||||
|
||||
/** DNS type name for common codes, `TYPE<n>` fallback (RFC 3597 style), em dash for null. */
|
||||
export function qtypeName(qtype: number | null): string {
|
||||
if (qtype === null) return "—";
|
||||
return QTYPE_NAMES[qtype] ?? `TYPE${qtype}`;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"/api/rules": {
|
||||
rules: [
|
||||
{
|
||||
id: 1,
|
||||
group_id: 1,
|
||||
group: "Default",
|
||||
pattern: "ads.example.com",
|
||||
kind: "exact",
|
||||
action: "block",
|
||||
created_at: 1700000000,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
group_id: 2,
|
||||
group: "Kids",
|
||||
pattern: "*.cdn.example.com",
|
||||
kind: "wildcard",
|
||||
action: "allow",
|
||||
created_at: 1700000100,
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
|
||||
// The API orders groups by name, so the id-1 default is not always first.
|
||||
let groups: { id: number; name: string; safe_search: boolean }[];
|
||||
let deleted: string[];
|
||||
let posted: { pattern: string; kind: string }[];
|
||||
|
||||
function deleteCalls(): string[] {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
groups = [
|
||||
{ id: 1, name: "Default", safe_search: false },
|
||||
{ id: 2, name: "Kids", safe_search: true },
|
||||
];
|
||||
deleted = [];
|
||||
posted = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (init?.method === "DELETE") {
|
||||
deleted.push(url);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url === "/api/rules" && init?.method === "POST") {
|
||||
posted.push(JSON.parse(String(init.body)) as { pattern: string; kind: string });
|
||||
return new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "Retry-After": "5" },
|
||||
});
|
||||
}
|
||||
const payload = url === "/api/groups" ? { groups } : RESPONSES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderRulesRoute() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/rules"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A RAC Select names its trigger with the current value and then the label, so
|
||||
* the label alone is a suffix match. Opening it is the only way to read the
|
||||
* options: there is no `<select>` carrying them any more.
|
||||
*/
|
||||
function trigger(label: string): HTMLElement {
|
||||
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
|
||||
}
|
||||
|
||||
async function optionsOf(label: string): Promise<(string | null)[]> {
|
||||
fireEvent.click(trigger(label));
|
||||
const options = await screen.findAllByRole("option");
|
||||
const labels = options.map((option) => option.textContent);
|
||||
// Re-picking the current value closes the listbox and changes nothing.
|
||||
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
return labels;
|
||||
}
|
||||
|
||||
test("renders the rule table and the create form with contract enums", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("ads.example.com")).toBeTruthy();
|
||||
expect(table.getByText("*.cdn.example.com")).toBeTruthy();
|
||||
expect(table.getByText("block")).toBeTruthy();
|
||||
expect(table.getByText("allow")).toBeTruthy();
|
||||
expect(table.getByText("Kids")).toBeTruthy();
|
||||
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
|
||||
|
||||
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard", "regex"]);
|
||||
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
|
||||
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
|
||||
});
|
||||
|
||||
test("the kind selector can select the regex option, not only list it", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
fireEvent.click(trigger("Kind"));
|
||||
const options = await screen.findAllByRole("option");
|
||||
const regex = options.find((option) => option.textContent === "regex");
|
||||
expect(regex).toBeTruthy();
|
||||
fireEvent.click(regex!);
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
|
||||
expect(trigger("Kind").textContent).toContain("regex");
|
||||
});
|
||||
|
||||
async function selectKind(label: string): Promise<void> {
|
||||
fireEvent.click(trigger("Kind"));
|
||||
const options = await screen.findAllByRole("option");
|
||||
fireEvent.click(options.find((option) => option.textContent === label)!);
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
}
|
||||
|
||||
// A regex is stored and matched byte for byte, so whitespace inside it is data,
|
||||
// not slop the UI may drop. Exact and wildcard are normalized server-side.
|
||||
test("a regex pattern is posted untrimmed, an exact pattern is trimmed", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
await selectKind("regex");
|
||||
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " foo|bar " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
||||
await waitFor(() => expect(posted).toHaveLength(1));
|
||||
expect(posted[0]).toMatchObject({ pattern: " foo|bar ", kind: "regex" });
|
||||
|
||||
await selectKind("exact");
|
||||
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
||||
await waitFor(() => expect(posted).toHaveLength(2));
|
||||
expect(posted[1]).toMatchObject({ pattern: "ads.example.net", kind: "exact" });
|
||||
});
|
||||
|
||||
test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
const input = screen.getByLabelText("Pattern");
|
||||
expect(input.getAttribute("autocapitalize")).toBe("none");
|
||||
expect(input.getAttribute("autocorrect")).toBe("off");
|
||||
expect(input.getAttribute("spellcheck")).toBe("false");
|
||||
});
|
||||
|
||||
test("rule create shows a countdown when rate limited with Retry-After", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 5s.");
|
||||
});
|
||||
|
||||
test("the group select preselects the id-1 default, not the alphabetically first group", async () => {
|
||||
groups = [
|
||||
{ id: 5, name: "Attic", safe_search: false },
|
||||
{ id: 1, name: "Default", safe_search: false },
|
||||
];
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
|
||||
expect(trigger("Group").textContent).toContain("Default");
|
||||
});
|
||||
|
||||
test("the group select falls back to the first group when the default is absent", async () => {
|
||||
groups = [
|
||||
{ id: 5, name: "Attic", safe_search: false },
|
||||
{ id: 7, name: "Basement", safe_search: false },
|
||||
];
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
expect(trigger("Group").textContent).toContain("Attic");
|
||||
});
|
||||
|
||||
test("delete asks for confirmation, and cancelling sends no request", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(deleteCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
test("confirming the delete dialog issues the DELETE for that rule", async () => {
|
||||
renderRulesRoute();
|
||||
await screen.findByRole("heading", { name: "Rules" });
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
|
||||
await screen.findByRole("alertdialog");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
|
||||
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: "exact", label: "exact" },
|
||||
{ value: "wildcard", label: "wildcard" },
|
||||
{ value: "regex", label: "regex" },
|
||||
];
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: "allow", label: "allow" },
|
||||
{ value: "block", label: "block" },
|
||||
];
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
pattern: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
allow: {
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
block: {
|
||||
color: colors.danger,
|
||||
},
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1.5rem",
|
||||
maxWidth: "36rem",
|
||||
},
|
||||
formHeading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
/** One column on a phone, three from the `sm` breakpoint, as before. */
|
||||
fieldGrid: {
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
submitRow: {
|
||||
display: "flex",
|
||||
},
|
||||
});
|
||||
|
||||
export default function RulesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: rules } = useSuspenseQuery(rulesQuery());
|
||||
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||
|
||||
const create = useMutation(ruleCreateMutation(queryClient));
|
||||
const remove = useMutation(ruleDeleteMutation(queryClient));
|
||||
|
||||
const [pattern, setPattern] = useState("");
|
||||
const [kind, setKind] = useState<RuleKind>("exact");
|
||||
const [action, setAction] = useState<RuleAction>("block");
|
||||
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
|
||||
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// A regex pattern is stored and matched byte for byte, so the UI must not
|
||||
// edit it: trimming here would make a UI-created rule differ from the same
|
||||
// bytes posted to /api/rules. Name-shaped kinds are normalized server-side,
|
||||
// so trimming them only spares a pasted space a 400.
|
||||
const sent = kind === "regex" ? pattern : pattern.trim();
|
||||
create.mutate({ group_id: groupId, pattern: sent, kind, action }, { onSuccess: () => setPattern("") });
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (pendingDelete === null) return;
|
||||
remove.mutate(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Rules</h1>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>Pattern</th>
|
||||
<th {...stylex.props(shared.th)}>Kind</th>
|
||||
<th {...stylex.props(shared.th)}>Action</th>
|
||||
<th {...stylex.props(shared.th)}>Group</th>
|
||||
<th {...stylex.props(shared.th)}>Created</th>
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
|
||||
<td {...stylex.props(shared.td)}>{rule.kind}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
|
||||
{rule.action}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{rule.group}</td>
|
||||
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(rule)}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={remove.error} />
|
||||
|
||||
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||
<h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
|
||||
<div>
|
||||
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
|
||||
Pattern
|
||||
</label>
|
||||
<input
|
||||
id="rule-pattern"
|
||||
type="text"
|
||||
required
|
||||
value={pattern}
|
||||
onChange={(event) => setPattern(event.target.value)}
|
||||
placeholder="ads.example.com, *.example.com or ^ad[0-9]+-"
|
||||
// A phone keyboard capitalizing the first letter is silent for
|
||||
// exact and wildcard (normalized server-side) but fatal for a
|
||||
// regex, which matches the lowercase query name byte for byte.
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div {...stylex.props(styles.fieldGrid)}>
|
||||
<Select
|
||||
label="Kind"
|
||||
value={kind}
|
||||
onChange={(value) => setKind(value as RuleKind)}
|
||||
options={KIND_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
label="Action"
|
||||
value={action}
|
||||
onChange={(value) => setAction(value as RuleAction)}
|
||||
options={ACTION_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
label="Group"
|
||||
value={String(groupId)}
|
||||
onChange={(value) => setGroupId(Number(value))}
|
||||
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||
/>
|
||||
</div>
|
||||
<div {...stylex.props(styles.submitRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
{create.isPending ? "Creating…" : "Create rule"}
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={create.error} />
|
||||
</form>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete rule"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { useAuthority } from "./authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
banner: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.warnBorder,
|
||||
backgroundColor: colors.warnSurface,
|
||||
color: colors.warnText,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* File authority is a standing condition, not an event, so this banner has no
|
||||
* dismiss button: it stays up for as long as the process runs from a file.
|
||||
*/
|
||||
export default function ReadOnlyConfigBanner() {
|
||||
const authority = useAuthority();
|
||||
if (authority?.mode !== "managed_file") return null;
|
||||
return (
|
||||
<div role="status" {...stylex.props(styles.banner)}>
|
||||
Configuration is managed by <code {...stylex.props(shared.mono)}>{authority.path}</code>. Edit the file and
|
||||
restart nxdns to change it; the server rejects edits made here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import RestartBanner from "@/features/settings/RestartBanner";
|
||||
import { dismissRestartBanner, raiseRestartBanner } from "@/features/settings/restartBanner";
|
||||
|
||||
beforeEach(() => {
|
||||
act(() => dismissRestartBanner());
|
||||
});
|
||||
|
||||
test("hidden until raised, dismissible, and a new raise shows it again", () => {
|
||||
render(<RestartBanner />);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
|
||||
act(() => raiseRestartBanner());
|
||||
expect(screen.getByRole("status").textContent).toContain("Restart nxdns to apply");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
|
||||
act(() => raiseRestartBanner());
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("raising while already raised keeps the banner up", () => {
|
||||
render(<RestartBanner />);
|
||||
act(() => raiseRestartBanner());
|
||||
act(() => raiseRestartBanner());
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
|
||||
|
||||
const styles = stylex.create({
|
||||
banner: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.warnBorder,
|
||||
backgroundColor: colors.warnSurface,
|
||||
color: colors.warnText,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
message: {
|
||||
flex: 1,
|
||||
},
|
||||
dismiss: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.warnBorderStrong,
|
||||
backgroundColor: "transparent",
|
||||
color: "inherit",
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function RestartBanner() {
|
||||
const raised = useRestartBanner();
|
||||
if (!raised) return null;
|
||||
return (
|
||||
<div role="status" {...stylex.props(styles.banner)}>
|
||||
<span {...stylex.props(styles.message)}>Changes saved. Restart nxdns to apply.</span>
|
||||
<button type="button" onClick={dismissRestartBanner} {...stylex.props(styles.dismiss, shared.focusRing)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { Suspense } from "react";
|
||||
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act } from "react";
|
||||
import SettingsPage, { patchRequiresRestart } from "@/features/settings/SettingsPage";
|
||||
import RestartBanner from "@/features/settings/RestartBanner";
|
||||
import { dismissRestartBanner } from "@/features/settings/restartBanner";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
|
||||
function baseSettings(): Settings {
|
||||
return {
|
||||
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
||||
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
|
||||
blocking: { response: "zero", ttl: 300 },
|
||||
cache: { size: 10000, negative_ttl_max: 300 },
|
||||
web: {
|
||||
enabled: true,
|
||||
bind: "127.0.0.1",
|
||||
port: 8080,
|
||||
session_ttl_hours: 24,
|
||||
api_rate_limit_per_min: 60,
|
||||
api_localhost_exempt: true,
|
||||
sse_max_connections_per_ip: 2,
|
||||
trusted_proxies: "",
|
||||
auth_enabled: true,
|
||||
},
|
||||
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
||||
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
||||
edns: { ecs_mode: "strip" },
|
||||
logging: {
|
||||
level: "info",
|
||||
retention_days: 30,
|
||||
query_log_buffer_max: 10000,
|
||||
hide_domains: false,
|
||||
hide_client_ips: false,
|
||||
output: "stderr",
|
||||
file_path: "",
|
||||
max_size_mb: 50,
|
||||
max_files: 3,
|
||||
},
|
||||
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
||||
blocklist_update: { enabled: true, interval_hours: 24 },
|
||||
};
|
||||
}
|
||||
|
||||
let putBodies: SettingsPatch[];
|
||||
let putResponse: () => Response | Promise<Response>;
|
||||
let storedSettings: Settings;
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function applyPatch(patch: SettingsPatch): void {
|
||||
const settings = storedSettings as unknown as Record<string, Record<string, unknown>>;
|
||||
for (const [section, fields] of Object.entries(patch)) {
|
||||
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
||||
if (section === "web" && key === "password") continue;
|
||||
settings[section]![key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
act(() => dismissRestartBanner());
|
||||
putBodies = [];
|
||||
storedSettings = baseSettings();
|
||||
putResponse = () => {
|
||||
applyPatch(putBodies[putBodies.length - 1]!);
|
||||
return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] });
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url !== "/api/settings") return jsonResponse({ error: "not stubbed" }, 404);
|
||||
if (init?.method === "PUT") {
|
||||
putBodies.push(JSON.parse(String(init.body)) as SettingsPatch);
|
||||
return putResponse();
|
||||
}
|
||||
return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderPage(): Promise<QueryClient> {
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RestartBanner />
|
||||
<Suspense fallback={<p>loading</p>}>
|
||||
<SettingsPage />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Settings" });
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
function saveButton(): HTMLButtonElement {
|
||||
return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
test("no changes means Save is disabled and auth_enabled shows read-only", async () => {
|
||||
await renderPage();
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
expect(screen.getByText(/auth_enabled: true/).textContent).toContain("read-only");
|
||||
});
|
||||
|
||||
test("a changed field enables Save and the PUT body is exactly the diff", async () => {
|
||||
await renderPage();
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
|
||||
|
||||
expect((await screen.findByRole("status")).textContent).toContain("Restart nxdns to apply");
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
||||
});
|
||||
|
||||
test("enum and boolean fields diff as their own types", async () => {
|
||||
await renderPage();
|
||||
const logging = screen.getByRole("group", { name: "Logging" });
|
||||
// A RAC Select names its trigger with the current value and then the label, and
|
||||
// carries the options only while the listbox is open.
|
||||
fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "debug" }));
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
fireEvent.click(within(logging).getByLabelText("hide_domains"));
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
||||
});
|
||||
|
||||
test("clearing a number field disables Save instead of sending NaN", async () => {
|
||||
await renderPage();
|
||||
const cache = screen.getByRole("group", { name: "Cache" });
|
||||
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("password flow: note shown, confirm required, PUT sends web.password, no banner", async () => {
|
||||
await renderPage();
|
||||
const web = screen.getByRole("group", { name: "Web" });
|
||||
const passwordInput = within(web).getByLabelText("password") as HTMLInputElement;
|
||||
const confirmInput = within(web).getByLabelText("confirm password") as HTMLInputElement;
|
||||
expect(passwordInput.value).toBe("");
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
|
||||
expect(screen.getByText(/signs out every session/)).toBeTruthy();
|
||||
expect(screen.getByText("Passwords do not match.")).toBeTruthy();
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
|
||||
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
|
||||
expect(screen.queryByText("Passwords do not match.")).toBeNull();
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ web: { password: "hunter2" } });
|
||||
|
||||
await waitFor(() => expect(passwordInput.value).toBe(""));
|
||||
expect(confirmInput.value).toBe("");
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
test("a mixed patch with a password still raises the banner", async () => {
|
||||
await renderPage();
|
||||
const web = screen.getByRole("group", { name: "Web" });
|
||||
fireEvent.change(within(web).getByLabelText("session_ttl_hours"), { target: { value: "48" } });
|
||||
fireEvent.change(within(web).getByLabelText("password"), { target: { value: "hunter2" } });
|
||||
fireEvent.change(within(web).getByLabelText("confirm password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
|
||||
expect(await screen.findByRole("status")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
|
||||
await renderPage();
|
||||
let resolvePut!: (response: Response) => void;
|
||||
putResponse = () => new Promise<Response>((resolve) => (resolvePut = resolve));
|
||||
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
const port = within(dns).getByLabelText("port") as HTMLInputElement;
|
||||
fireEvent.change(port, { target: { value: "5353" } });
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await screen.findByRole("button", { name: "Saving…" });
|
||||
expect(port.matches(":disabled")).toBe(true);
|
||||
const web = screen.getByRole("group", { name: "Web" });
|
||||
expect(within(web).getByLabelText("password").matches(":disabled")).toBe(true);
|
||||
|
||||
applyPatch(putBodies[putBodies.length - 1]!);
|
||||
resolvePut(jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] }));
|
||||
await waitFor(() => expect(port.matches(":disabled")).toBe(false));
|
||||
expect(saveButton().textContent).toBe("Save");
|
||||
});
|
||||
|
||||
test("a 429 shows the rate-limit countdown from Retry-After", async () => {
|
||||
await renderPage();
|
||||
putResponse = () =>
|
||||
new Response(JSON.stringify({ error: "too many requests" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "Retry-After": "30" },
|
||||
});
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 30s.");
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
test("a 400 validation error surfaces inline and raises no banner", async () => {
|
||||
await renderPage();
|
||||
putResponse = () => jsonResponse({ error: "dns.port out of range" }, 400);
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "70000" } });
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toBe("dns.port out of range");
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("patchRequiresRestart ignores only a bare web.password", () => {
|
||||
expect(patchRequiresRestart({ web: { password: "x" } })).toBe(false);
|
||||
expect(patchRequiresRestart({ web: { password: "x", port: 9090 } })).toBe(true);
|
||||
expect(patchRequiresRestart({ dns: { port: 5353 } })).toBe(true);
|
||||
expect(patchRequiresRestart({ web: { password: "x" }, cache: { size: 1 } })).toBe(true);
|
||||
});
|
||||
|
||||
test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
|
||||
const queryClient = await renderPage();
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
|
||||
|
||||
// Someone else changes cache.size; a background refetch brings it in. The
|
||||
// derived auth_enabled line is read straight from the query data, so it
|
||||
// witnesses that the refetch reached the component.
|
||||
storedSettings.cache.size = 99999;
|
||||
storedSettings.web.auth_enabled = false;
|
||||
await act(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText(/auth_enabled: false/)).toBeTruthy());
|
||||
const cache = screen.getByRole("group", { name: "Cache" });
|
||||
expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000");
|
||||
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
|
||||
});
|
||||
|
||||
test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => {
|
||||
await renderPage();
|
||||
const dns = screen.getByRole("group", { name: "DNS" });
|
||||
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
||||
|
||||
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5454" } });
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(putBodies).toHaveLength(2));
|
||||
expect(putBodies[1]).toEqual({ dns: { port: 5454 } });
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { settingsPutMutation, settingsQuery } from "@/lib/queries";
|
||||
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
import { raiseRestartBanner } from "./restartBanner";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
|
||||
export function patchRequiresRestart(patch: SettingsPatch): boolean {
|
||||
return Object.entries(patch).some(([section, fields]) =>
|
||||
Object.keys(fields ?? {}).some((key) => !(section === "web" && key === "password")),
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldDef<S extends keyof Settings> {
|
||||
key: keyof Settings[S] & string;
|
||||
kind: "number" | "text" | "boolean" | readonly string[];
|
||||
}
|
||||
|
||||
interface SectionDef<S extends keyof Settings> {
|
||||
section: S;
|
||||
title: string;
|
||||
fields: readonly FieldDef<S>[];
|
||||
}
|
||||
|
||||
/** Binds each section's field keys to that section's Settings type at definition. */
|
||||
function defineSection<S extends keyof Settings>(def: SectionDef<S>): SectionDef<S> {
|
||||
return def;
|
||||
}
|
||||
|
||||
/** The registry read back as a heterogeneous list, once the per-section binding has been proven. */
|
||||
type AnyFieldDef = { [S in keyof Settings]: FieldDef<S> }[keyof Settings];
|
||||
type AnySectionDef = { [S in keyof Settings]: SectionDef<S> }[keyof Settings];
|
||||
|
||||
/**
|
||||
* A section's values as a string-keyed view. The keys are proven against
|
||||
* `Settings[S]` where each section is defined; iterating the heterogeneous
|
||||
* registry loses that correlation, so consumption widens here in one place.
|
||||
*/
|
||||
function sectionValues(settings: Settings, section: keyof Settings): Record<string, unknown> {
|
||||
return settings[section] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const TLS_FIELDS: readonly FieldDef<"doh_server" | "dot_server">[] = [
|
||||
{ key: "enabled", kind: "boolean" },
|
||||
{ key: "bind", kind: "text" },
|
||||
{ key: "port", kind: "number" },
|
||||
{ key: "cert_path", kind: "text" },
|
||||
{ key: "key_path", kind: "text" },
|
||||
];
|
||||
|
||||
const SECTIONS: readonly AnySectionDef[] = [
|
||||
defineSection({
|
||||
section: "upstream",
|
||||
title: "Upstream",
|
||||
fields: [
|
||||
{ key: "attempt_timeout_ms", kind: "number" },
|
||||
{ key: "read_timeout_ms", kind: "number" },
|
||||
{ key: "total_timeout_ms", kind: "number" },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
section: "dns",
|
||||
title: "DNS",
|
||||
fields: [
|
||||
{ key: "bind_ipv4", kind: "text" },
|
||||
{ key: "bind_ipv6", kind: "text" },
|
||||
{ key: "port", kind: "number" },
|
||||
{ key: "rate_limit", kind: "number" },
|
||||
{ key: "rate_window_seconds", kind: "number" },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
section: "blocking",
|
||||
title: "Blocking",
|
||||
fields: [
|
||||
{ key: "response", kind: ["zero", "nxdomain"] },
|
||||
{ key: "ttl", kind: "number" },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
section: "cache",
|
||||
title: "Cache",
|
||||
fields: [
|
||||
{ key: "size", kind: "number" },
|
||||
{ key: "negative_ttl_max", kind: "number" },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
section: "web",
|
||||
title: "Web",
|
||||
fields: [
|
||||
{ key: "enabled", kind: "boolean" },
|
||||
{ key: "bind", kind: "text" },
|
||||
{ key: "port", kind: "number" },
|
||||
{ key: "session_ttl_hours", kind: "number" },
|
||||
{ key: "api_rate_limit_per_min", kind: "number" },
|
||||
{ key: "api_localhost_exempt", kind: "boolean" },
|
||||
{ key: "sse_max_connections_per_ip", kind: "number" },
|
||||
{ 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: "logging",
|
||||
title: "Logging",
|
||||
fields: [
|
||||
{ key: "level", kind: ["error", "warn", "info", "debug"] },
|
||||
{ key: "retention_days", kind: "number" },
|
||||
{ key: "query_log_buffer_max", kind: "number" },
|
||||
{ key: "hide_domains", kind: "boolean" },
|
||||
{ key: "hide_client_ips", kind: "boolean" },
|
||||
{ key: "output", kind: ["stderr", "syslog", "file"] },
|
||||
{ key: "file_path", kind: "text" },
|
||||
{ key: "max_size_mb", kind: "number" },
|
||||
{ key: "max_files", kind: "number" },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
section: "disk",
|
||||
title: "Disk",
|
||||
fields: [
|
||||
{ key: "min_free_mb", kind: "number" },
|
||||
{ key: "warn_free_mb", kind: "number" },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
section: "blocklist_update",
|
||||
title: "Blocklist Update",
|
||||
fields: [
|
||||
{ key: "enabled", kind: "boolean" },
|
||||
{ key: "interval_hours", kind: "number" },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
form: {
|
||||
marginTop: "1rem",
|
||||
maxWidth: "48rem",
|
||||
},
|
||||
/** A `fieldset` has a browser default border and padding; the layout wants neither. */
|
||||
sections: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.5rem",
|
||||
borderStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
},
|
||||
section: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
padding: "1rem",
|
||||
},
|
||||
legend: {
|
||||
paddingInline: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
/** One column on a phone, two from `sm`, as before. */
|
||||
fieldGrid: {
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
label: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: {
|
||||
default: "oklch(37% 0.013 285.805)",
|
||||
[DARK]: "oklch(87.1% 0.006 286.286)",
|
||||
},
|
||||
},
|
||||
checkboxRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
field: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
fieldInput: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
derived: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** Both notices span the whole grid so the wrapped sentence stays readable. */
|
||||
spanRow: {
|
||||
gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" },
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
passwordNotice: {
|
||||
color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" },
|
||||
},
|
||||
mismatchNotice: {
|
||||
color: colors.danger,
|
||||
},
|
||||
submitRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
save: {
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
backgroundColor: {
|
||||
default: colors.primary,
|
||||
":disabled": "oklch(87.1% 0.006 286.286)",
|
||||
[DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" },
|
||||
},
|
||||
color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" },
|
||||
},
|
||||
});
|
||||
|
||||
function FieldRow({
|
||||
section,
|
||||
def,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
section: string;
|
||||
def: AnyFieldDef;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const id = `${section}.${def.key}`;
|
||||
if (def.kind === "boolean") {
|
||||
return (
|
||||
<div {...stylex.props(styles.checkboxRow)}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={value as boolean}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
<label htmlFor={id} {...stylex.props(styles.label)}>
|
||||
{def.key}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(def.kind)) {
|
||||
return (
|
||||
<Select
|
||||
variant="inline"
|
||||
label={def.key}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
options={def.kind.map((option) => ({ value: option, label: option }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (def.kind === "number") {
|
||||
const numeric = value as number;
|
||||
return (
|
||||
<div {...stylex.props(styles.field)}>
|
||||
<label htmlFor={id} {...stylex.props(styles.label)}>
|
||||
{def.key}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
value={Number.isNaN(numeric) ? "" : numeric}
|
||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div {...stylex.props(styles.field)}>
|
||||
<label htmlFor={id} {...stylex.props(styles.label)}>
|
||||
{def.key}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value as string}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data } = useSuspenseQuery(settingsQuery());
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(settingsPutMutation(queryClient));
|
||||
// Frozen at mount and re-frozen on save: diffing against live query data would
|
||||
// turn a background refetch's out-of-band changes into phantom user edits.
|
||||
const [baseline, setBaseline] = useState<Settings>(() => structuredClone(data.settings));
|
||||
const [edited, setEdited] = useState<Settings>(() => structuredClone(data.settings));
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
|
||||
const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm;
|
||||
const hasInvalidNumber = SECTIONS.some(({ section, fields }) => {
|
||||
const values = sectionValues(edited, section);
|
||||
return (fields as readonly AnyFieldDef[]).some(
|
||||
(field) => field.kind === "number" && Number.isNaN(values[field.key]),
|
||||
);
|
||||
});
|
||||
const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password);
|
||||
const readOnly = useReadOnlyConfig();
|
||||
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly;
|
||||
|
||||
function setField(section: keyof Settings, key: string, value: unknown): void {
|
||||
setEdited((prev) => ({
|
||||
...prev,
|
||||
[section]: { ...sectionValues(prev, section), [key]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent): void {
|
||||
event.preventDefault();
|
||||
if (patch === null || passwordsMismatch || hasInvalidNumber) return;
|
||||
const restartNeeded = patchRequiresRestart(patch);
|
||||
mutation.mutate(patch, {
|
||||
onSuccess: (envelope) => {
|
||||
setBaseline(structuredClone(envelope.settings));
|
||||
setEdited(structuredClone(envelope.settings));
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
if (restartNeeded) raiseRestartBanner();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Settings</h1>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
Changes are validated as a whole; every setting requires a restart to take effect.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||
<fieldset disabled={mutation.isPending || readOnly} {...stylex.props(styles.sections)}>
|
||||
{SECTIONS.map(({ section, title, fields }) => (
|
||||
<fieldset key={section} {...stylex.props(styles.section)}>
|
||||
<legend {...stylex.props(styles.legend)}>{title}</legend>
|
||||
<div {...stylex.props(styles.fieldGrid)}>
|
||||
{(fields as readonly AnyFieldDef[]).map((def) => (
|
||||
<FieldRow
|
||||
key={def.key}
|
||||
section={section}
|
||||
def={def}
|
||||
value={sectionValues(edited, section)[def.key]}
|
||||
onChange={(value) => setField(section, def.key, value)}
|
||||
/>
|
||||
))}
|
||||
{section === "web" && (
|
||||
<>
|
||||
<p {...stylex.props(styles.label)}>
|
||||
auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "}
|
||||
<span {...stylex.props(styles.derived)}>(derived, read-only)</span>
|
||||
</p>
|
||||
<div {...stylex.props(styles.field)}>
|
||||
<label htmlFor="web.password" {...stylex.props(styles.label)}>
|
||||
password
|
||||
</label>
|
||||
<input
|
||||
id="web.password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div {...stylex.props(styles.field)}>
|
||||
<label htmlFor="web.password_confirm" {...stylex.props(styles.label)}>
|
||||
confirm password
|
||||
</label>
|
||||
<input
|
||||
id="web.password_confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
{password !== "" && (
|
||||
<p {...stylex.props(styles.spanRow, styles.passwordNotice)}>
|
||||
Changing the password signs out every session; you will be asked to log
|
||||
in again.
|
||||
</p>
|
||||
)}
|
||||
{passwordsMismatch && (
|
||||
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||
Passwords do not match.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<div {...stylex.props(styles.submitRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saveDisabled}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(styles.save, shared.focusRing)}
|
||||
>
|
||||
{mutation.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{mutation.isError && <InlineError error={mutation.error} />}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Authority, Settings, SettingsEnvelope } from "@/lib/types";
|
||||
|
||||
// One file for the whole file-mode sweep: the settings envelope is the only
|
||||
// discovery mechanism, so every page test needs the same stubbed envelope.
|
||||
|
||||
const CONFIG_PATH = "/etc/nxdns/config.zon";
|
||||
|
||||
const DATABASE: Authority = { mode: "database", path: null, reconciled_at: null };
|
||||
const MANAGED_FILE: Authority = { mode: "managed_file", path: CONFIG_PATH, reconciled_at: 1754899200 };
|
||||
|
||||
function baseSettings(): Settings {
|
||||
return {
|
||||
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
||||
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
|
||||
blocking: { response: "zero", ttl: 300 },
|
||||
cache: { size: 10000, negative_ttl_max: 300 },
|
||||
web: {
|
||||
enabled: true,
|
||||
bind: "127.0.0.1",
|
||||
port: 8080,
|
||||
session_ttl_hours: 24,
|
||||
api_rate_limit_per_min: 60,
|
||||
api_localhost_exempt: true,
|
||||
sse_max_connections_per_ip: 2,
|
||||
trusted_proxies: "",
|
||||
auth_enabled: true,
|
||||
},
|
||||
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
||||
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
||||
edns: { ecs_mode: "strip" },
|
||||
logging: {
|
||||
level: "info",
|
||||
retention_days: 30,
|
||||
query_log_buffer_max: 10000,
|
||||
hide_domains: false,
|
||||
hide_client_ips: false,
|
||||
output: "stderr",
|
||||
file_path: "",
|
||||
max_size_mb: 50,
|
||||
max_files: 3,
|
||||
},
|
||||
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
||||
blocklist_update: { enabled: true, interval_hours: 24 },
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(authority: Authority): SettingsEnvelope {
|
||||
return { settings: baseSettings(), restart_required: [], authority };
|
||||
}
|
||||
|
||||
const GROUPS = {
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
};
|
||||
|
||||
const BLOCKLISTS = {
|
||||
blocklists: [
|
||||
{
|
||||
id: 1,
|
||||
url: "https://example.com/ads.txt",
|
||||
name: "Ads",
|
||||
enabled: true,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
domain_count: 100,
|
||||
wildcard_count: 0,
|
||||
skipped_regex_count: 0,
|
||||
checksum: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RULES = {
|
||||
rules: [
|
||||
{
|
||||
id: 1,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
pattern: "ads.example.com",
|
||||
kind: "exact",
|
||||
action: "block",
|
||||
created_at: 1700000000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CLIENTS = {
|
||||
clients: [
|
||||
{
|
||||
id: 1,
|
||||
ip: "192.168.1.10",
|
||||
name: "laptop",
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: true,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700003600,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ip: "192.168.1.11",
|
||||
name: "",
|
||||
group_id: 2,
|
||||
group: "kids",
|
||||
hand_edited: false,
|
||||
first_seen: 1700000000,
|
||||
last_seen: 1700007200,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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 BASE: Record<string, unknown> = {
|
||||
"GET /api/version": VERSION,
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/blocklists": BLOCKLISTS,
|
||||
"GET /api/rules": RULES,
|
||||
"GET /api/clients": CLIENTS,
|
||||
"GET /api/client-prefixes": PREFIXES,
|
||||
};
|
||||
|
||||
function stubFetch(map: Record<string, unknown>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||
const payload = map[key];
|
||||
if (payload === undefined) {
|
||||
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function renderAt(route: string, heading: string, authority: Authority) {
|
||||
stubFetch({ ...BASE, "GET /api/settings": envelope(authority) });
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: heading });
|
||||
}
|
||||
|
||||
function button(name: string): HTMLButtonElement {
|
||||
return screen.getByRole("button", { name }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function clientRow(ip: string): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr");
|
||||
if (row === null) throw new Error(`no client row for ${ip}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("the banner names the managed file in file mode", async () => {
|
||||
await renderAt("/rules", "Rules", MANAGED_FILE);
|
||||
|
||||
const banner = await screen.findByText(/configuration is managed by/i);
|
||||
expect(banner.textContent).toContain(CONFIG_PATH);
|
||||
expect(banner.textContent).toMatch(/restart/i);
|
||||
expect(banner.closest('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the banner is absent in database mode", async () => {
|
||||
await renderAt("/rules", "Rules", DATABASE);
|
||||
|
||||
await screen.findByRole("button", { name: "Create rule" });
|
||||
expect(screen.queryByText(/configuration is managed by/i)).toBeNull();
|
||||
});
|
||||
|
||||
function kidsRow(): HTMLElement {
|
||||
const row = screen.getByText("kids").closest("li");
|
||||
if (row === null) throw new Error("no row for group kids");
|
||||
return row;
|
||||
}
|
||||
|
||||
test("file mode disables the Groups create and delete controls", async () => {
|
||||
await renderAt("/groups", "Groups", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Create").disabled).toBe(true);
|
||||
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("database mode leaves the Groups create and delete controls enabled", async () => {
|
||||
await renderAt("/groups", "Groups", DATABASE);
|
||||
await screen.findByRole("button", { name: "Create" });
|
||||
|
||||
expect(button("Create").disabled).toBe(false);
|
||||
expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode disables the Rules create and delete controls", async () => {
|
||||
await renderAt("/rules", "Rules", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Create rule").disabled).toBe(true);
|
||||
expect(button("Delete").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("database mode leaves the Rules create and delete controls enabled", async () => {
|
||||
await renderAt("/rules", "Rules", DATABASE);
|
||||
await screen.findByRole("button", { name: "Create rule" });
|
||||
|
||||
expect(button("Create rule").disabled).toBe(false);
|
||||
expect(button("Delete").disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode keeps delete live for an observed client and blocks it for a declared one", async () => {
|
||||
await renderAt("/clients", "Clients", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
const declared = clientRow("192.168.1.10");
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((within(observed).getByRole("button", { name: "Edit" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("file mode leaves the blocklist refresh button enabled", async () => {
|
||||
await renderAt("/blocklists", "Blocklists", MANAGED_FILE);
|
||||
await screen.findByText(/configuration is managed by/i);
|
||||
|
||||
expect(button("Update now").disabled).toBe(false);
|
||||
expect(button("Add source").disabled).toBe(true);
|
||||
expect((screen.getByRole("checkbox", { name: "Ads enabled" }) as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { settingsQuery } from "@/lib/queries";
|
||||
import type { Authority } from "@/lib/types";
|
||||
|
||||
/** The one-line explanation on every control file authority takes away. */
|
||||
export const READ_ONLY_HINT = "Configuration is managed by a file; edit the file and restart nxdns.";
|
||||
|
||||
/**
|
||||
* The running server's configuration authority, read from the settings
|
||||
* envelope — the only route that carries it. `undefined` until that query
|
||||
* resolves. Every page may call this: it is the shared `["settings"]` key, so
|
||||
* the shell's own subscription serves them all from cache.
|
||||
*/
|
||||
export function useAuthority(): Authority | undefined {
|
||||
return useQuery(settingsQuery()).data?.authority;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only once the server has said a file owns the configuration. While the
|
||||
* mode is unknown nothing is disabled — the 403 is the enforcement, this is
|
||||
* the courtesy.
|
||||
*/
|
||||
export function useReadOnlyConfig(): boolean {
|
||||
return useAuthority()?.mode === "managed_file";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
let raised = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return raised;
|
||||
}
|
||||
|
||||
export function raiseRestartBanner(): void {
|
||||
raised = true;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function dismissRestartBanner(): void {
|
||||
raised = false;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function useRestartBanner(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
||||
|
||||
const DEFAULT_PRIORITY = "100";
|
||||
|
||||
interface UpstreamFormProps {
|
||||
initial?: Upstream;
|
||||
busy: boolean;
|
||||
/** File authority: the server answers 403, so the submit stays down. */
|
||||
readOnly: boolean;
|
||||
error: Error | null;
|
||||
onSubmit: (input: UpstreamInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1rem",
|
||||
maxWidth: "36rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
hint: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
cancelButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
});
|
||||
|
||||
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
|
||||
const [url, setUrl] = useState(initial?.url ?? "");
|
||||
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
const [tlsName, setTlsName] = useState(initial?.tls_name ?? "");
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const parsed = Number(priority);
|
||||
try {
|
||||
// PUT replaces the row, so every field goes on every submit.
|
||||
await onSubmit({
|
||||
url: url.trim(),
|
||||
priority: Number.isFinite(parsed) ? parsed : 0,
|
||||
enabled,
|
||||
tls_name: tlsName.trim(),
|
||||
});
|
||||
if (initial === undefined) {
|
||||
setUrl("");
|
||||
setPriority(DEFAULT_PRIORITY);
|
||||
setEnabled(true);
|
||||
setTlsName("");
|
||||
}
|
||||
} catch {
|
||||
// The page renders the mutation error inline below the form.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
|
||||
<div>
|
||||
<label htmlFor="upstream-url" {...stylex.props(styles.fieldLabel)}>
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
id="upstream-url"
|
||||
type="text"
|
||||
required
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="udp://1.1.1.1:53"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="upstream-priority" {...stylex.props(styles.fieldLabel)}>
|
||||
Priority
|
||||
</label>
|
||||
<input
|
||||
id="upstream-priority"
|
||||
type="number"
|
||||
min={0}
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="upstream-tls-name" {...stylex.props(styles.fieldLabel)}>
|
||||
TLS name
|
||||
</label>
|
||||
<input
|
||||
id="upstream-tls-name"
|
||||
type="text"
|
||||
value={tlsName}
|
||||
onChange={(event) => setTlsName(event.target.value)}
|
||||
placeholder="one.one.one.one"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
<p {...stylex.props(styles.hint)}>
|
||||
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
|
||||
</p>
|
||||
</div>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
{initial === undefined ? "Add upstream" : "Save changes"}
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
{...stylex.props(shared.button, styles.cancelButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<InlineError error={error} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { dismissRestartBanner } from "@/features/settings/restartBanner";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
const UPSTREAMS = {
|
||||
upstreams: [
|
||||
{ id: 1, url: "udp://1.1.1.1:53", priority: 100, enabled: true, tls_name: "" },
|
||||
{ id: 2, url: "tls://9.9.9.9:853", priority: 200, enabled: false, tls_name: "dns.quad9.net" },
|
||||
],
|
||||
};
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"/api/upstreams": UPSTREAMS,
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
method: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
let calls: Call[];
|
||||
let writeResponse: (() => Response) | null;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
writeResponse = null;
|
||||
dismissRestartBanner();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? "GET";
|
||||
if (method !== "GET") {
|
||||
calls.push({
|
||||
url,
|
||||
method,
|
||||
body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined,
|
||||
});
|
||||
if (writeResponse !== null) return writeResponse();
|
||||
if (method === "DELETE") return new Response(null, { status: 204 });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 3,
|
||||
url: "udp://8.8.8.8:53",
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
tls_name: "",
|
||||
restart_required: true,
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const payload = RESPONSES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderUpstreamsRoute() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/upstreams"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Upstreams" });
|
||||
}
|
||||
|
||||
test("renders the upstream table and the add form", async () => {
|
||||
await renderUpstreamsRoute();
|
||||
|
||||
expect(screen.getByText("udp://1.1.1.1:53")).toBeTruthy();
|
||||
expect(screen.getByText("tls://9.9.9.9:853")).toBeTruthy();
|
||||
expect(screen.getByText("100")).toBeTruthy();
|
||||
expect(screen.getByText("200")).toBeTruthy();
|
||||
expect(screen.getByText("dns.quad9.net")).toBeTruthy();
|
||||
|
||||
const enabledToggle = screen.getByLabelText("udp://1.1.1.1:53 enabled") as HTMLInputElement;
|
||||
expect(enabledToggle.checked).toBe(true);
|
||||
const disabledToggle = screen.getByLabelText("tls://9.9.9.9:853 enabled") as HTMLInputElement;
|
||||
expect(disabledToggle.checked).toBe(false);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
|
||||
expect(screen.getByText(/reflects the running pool/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("adding an upstream posts every field and raises the restart banner", async () => {
|
||||
await renderUpstreamsRoute();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
|
||||
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "150" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
|
||||
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(calls[0]).toEqual({
|
||||
url: "/api/upstreams",
|
||||
method: "POST",
|
||||
body: { url: "udp://8.8.8.8:53", priority: 150, enabled: true, tls_name: "" },
|
||||
});
|
||||
|
||||
const banner = await screen.findByRole("status");
|
||||
expect(banner.textContent).toContain("Changes saved. Restart nxdns to apply.");
|
||||
});
|
||||
|
||||
test("toggling enabled resends the whole row", async () => {
|
||||
await renderUpstreamsRoute();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("tls://9.9.9.9:853 enabled"));
|
||||
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(calls[0]).toEqual({
|
||||
url: "/api/upstreams/2",
|
||||
method: "PUT",
|
||||
body: { url: "tls://9.9.9.9:853", priority: 200, enabled: true, tls_name: "dns.quad9.net" },
|
||||
});
|
||||
await screen.findByRole("status");
|
||||
});
|
||||
|
||||
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
|
||||
async function openDeleteDialog() {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
||||
return await screen.findByRole("alertdialog");
|
||||
}
|
||||
|
||||
test("delete asks for confirmation and skips the request when cancelled", async () => {
|
||||
await renderUpstreamsRoute();
|
||||
|
||||
const dialog = await openDeleteDialog();
|
||||
expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("confirming the delete dialog issues the DELETE and raises the restart banner", async () => {
|
||||
await renderUpstreamsRoute();
|
||||
|
||||
await openDeleteDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(calls[0]!.method).toBe("DELETE");
|
||||
expect(calls[0]!.url).toBe("/api/upstreams/1");
|
||||
await screen.findByRole("status");
|
||||
});
|
||||
|
||||
test("a 409 on create renders the conflict text inline", async () => {
|
||||
await renderUpstreamsRoute();
|
||||
|
||||
writeResponse = () =>
|
||||
new Response(JSON.stringify({ error: "an upstream with that url already exists" }), {
|
||||
status: 409,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://1.1.1.1:53" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("an upstream with that url already exists");
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
test("a 409 on toggle renders the last-enabled conflict above the form", async () => {
|
||||
await renderUpstreamsRoute();
|
||||
|
||||
writeResponse = () =>
|
||||
new Response(JSON.stringify({ error: "the last enabled upstream cannot be disabled" }), {
|
||||
status: 409,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("udp://1.1.1.1:53 enabled"));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("the last enabled upstream cannot be disabled");
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
test("a 409 on delete renders the last-enabled conflict", async () => {
|
||||
await renderUpstreamsRoute();
|
||||
|
||||
writeResponse = () =>
|
||||
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
|
||||
status: 409,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
await openDeleteDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
test("editing a row seeds the form and PUTs the replaced row", async () => {
|
||||
await renderUpstreamsRoute();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[1]!);
|
||||
await screen.findByRole("heading", { name: "Edit tls://9.9.9.9:853" });
|
||||
|
||||
expect((screen.getByLabelText("URL") as HTMLInputElement).value).toBe("tls://9.9.9.9:853");
|
||||
expect((screen.getByLabelText("Priority") as HTMLInputElement).value).toBe("200");
|
||||
expect((screen.getByLabelText("TLS name") as HTMLInputElement).value).toBe("dns.quad9.net");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "10" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(calls[0]).toEqual({
|
||||
url: "/api/upstreams/2",
|
||||
method: "PUT",
|
||||
body: { url: "tls://9.9.9.9:853", priority: 10, enabled: false, tls_name: "dns.quad9.net" },
|
||||
});
|
||||
await screen.findByRole("heading", { name: "Add upstream" });
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
|
||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import { raiseRestartBanner } from "../settings/restartBanner";
|
||||
import UpstreamForm from "./UpstreamForm";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.5rem",
|
||||
maxWidth: "42rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
url: {
|
||||
display: "block",
|
||||
maxWidth: "18rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontWeight: 500,
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
});
|
||||
|
||||
export default function UpstreamsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
|
||||
const [editing, setEditing] = useState<Upstream | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
|
||||
|
||||
const create = useMutation(upstreamCreateMutation(queryClient));
|
||||
const save = useMutation(upstreamUpdateMutation(queryClient));
|
||||
const toggle = useMutation(upstreamUpdateMutation(queryClient));
|
||||
const remove = useMutation(upstreamDeleteMutation(queryClient));
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
async function submitForm(input: UpstreamInput) {
|
||||
if (editing === null) {
|
||||
await create.mutateAsync(input);
|
||||
} else {
|
||||
await save.mutateAsync({ id: editing.id, input });
|
||||
setEditing(null);
|
||||
}
|
||||
raiseRestartBanner();
|
||||
}
|
||||
|
||||
function toggleEnabled(u: Upstream) {
|
||||
toggle.mutate(
|
||||
{
|
||||
id: u.id,
|
||||
input: { url: u.url, priority: u.priority, enabled: !u.enabled, tls_name: u.tls_name },
|
||||
},
|
||||
{ onSuccess: () => raiseRestartBanner() },
|
||||
);
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (pendingDelete === null) return;
|
||||
remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
const formError = editing === null ? create.error : save.error;
|
||||
const tableError = remove.error ?? toggle.error;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
|
||||
health table on the Dashboard reflects the running pool, not this list.
|
||||
</p>
|
||||
|
||||
{upstreams.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>No upstreams yet. Add one below.</p>
|
||||
) : (
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th {...stylex.props(shared.th)}>URL</th>
|
||||
<th {...stylex.props(shared.th)}>Priority</th>
|
||||
<th {...stylex.props(shared.th)}>Enabled</th>
|
||||
<th {...stylex.props(shared.th)}>TLS name</th>
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{upstreams.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(styles.url)} title={u.url}>
|
||||
{u.url}
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${u.url} enabled`}
|
||||
checked={u.enabled}
|
||||
disabled={toggle.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onChange={() => toggleEnabled(u)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(u)}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(
|
||||
shared.linkButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(u)}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={tableError} />
|
||||
|
||||
<UpstreamForm
|
||||
key={editing?.id ?? "add"}
|
||||
initial={editing ?? undefined}
|
||||
busy={editing === null ? create.isPending : save.isPending}
|
||||
readOnly={readOnly}
|
||||
error={formError}
|
||||
onSubmit={submitForm}
|
||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete upstream"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: `Delete upstream "${pendingDelete.url}"? Queries stop being forwarded to it.`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import InlineError from "./InlineError";
|
||||
|
||||
test("no retry button without onRetry", () => {
|
||||
render(<InlineError error={new ApiError(409, "already exists")} />);
|
||||
expect(screen.getByRole("alert").textContent).toBe("already exists");
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
|
||||
});
|
||||
|
||||
test("onRetry renders a focusable retry button that calls back", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Retry" });
|
||||
// The accessibility floor: StyleX compiles the ring to opaque class names, so
|
||||
// the check is that every class `focusRing` produces landed on the button.
|
||||
const ring = (stylex.props(shared.focusRing).className ?? "").split(" ");
|
||||
expect(button.className.split(" ")).toEqual(expect.arrayContaining(ring));
|
||||
fireEvent.click(button);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a null error renders nothing even with onRetry", () => {
|
||||
const { container } = render(<InlineError error={null} onRetry={() => undefined} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
message: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.danger,
|
||||
},
|
||||
retry: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
color: "inherit",
|
||||
fontSize: "inherit",
|
||||
fontWeight: 500,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with
|
||||
* countdown. Pass `onRetry` to append a retry button for a failed query.
|
||||
*/
|
||||
export default function InlineError({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
|
||||
const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
|
||||
const [remaining, setRemaining] = useState<number | null>(retryAfter);
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(retryAfter);
|
||||
if (retryAfter === null) return;
|
||||
const timer = setInterval(() => setRemaining((s) => (s === null || s <= 1 ? 0 : s - 1)), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [error, retryAfter]);
|
||||
|
||||
if (error === null || error === undefined) return null;
|
||||
|
||||
let message: string;
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 429) {
|
||||
message =
|
||||
remaining !== null && remaining > 0
|
||||
? `Rate limited. Try again in ${remaining}s.`
|
||||
: "Rate limited. Try again.";
|
||||
} else if (error.status === 503) {
|
||||
message = "The server is starting or degraded. Try again shortly.";
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
} else {
|
||||
message = "Could not reach the server.";
|
||||
}
|
||||
|
||||
return (
|
||||
<p role="alert" {...stylex.props(styles.message)}>
|
||||
{message}
|
||||
{onRetry !== undefined && (
|
||||
<>
|
||||
{" "}
|
||||
<button type="button" onClick={onRetry} {...stylex.props(styles.retry, shared.focusRing)}>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ApiError, deleteGroup, getQueries, getStats, listGroups, login, putGroupSources } from "@/lib/api";
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("sends same-origin credentials and parses JSON", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ authenticated: true, auth_required: true }));
|
||||
const response = await login({ password: "hunter2" });
|
||||
expect(response.auth_required).toBe(true);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe("/api/auth/login");
|
||||
expect(init?.credentials).toBe("same-origin");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.body).toBe(JSON.stringify({ password: "hunter2" }));
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers["content-type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
test("throws ApiError with the {error} envelope message", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ error: "duplicate group name" }, 409));
|
||||
const failure = await listGroups().catch((e: unknown) => e);
|
||||
expect(failure).toBeInstanceOf(ApiError);
|
||||
expect((failure as ApiError).status).toBe(409);
|
||||
expect((failure as ApiError).message).toBe("duplicate group name");
|
||||
expect((failure as ApiError).retryAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
test("falls back to a status message on a non-JSON error body", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("<html>bad gateway</html>", { status: 502 }));
|
||||
const failure = await listGroups().catch((e: unknown) => e);
|
||||
expect(failure).toBeInstanceOf(ApiError);
|
||||
expect((failure as ApiError).message).toBe("HTTP 502");
|
||||
});
|
||||
|
||||
test("parses Retry-After on 429", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "17" }));
|
||||
const failure = await getStats("1h").catch((e: unknown) => e);
|
||||
expect(failure).toBeInstanceOf(ApiError);
|
||||
expect((failure as ApiError).status).toBe(429);
|
||||
expect((failure as ApiError).retryAfter).toBe(17);
|
||||
});
|
||||
|
||||
test("ignores a malformed Retry-After header", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "soon" }));
|
||||
const failure = await getStats().catch((e: unknown) => e);
|
||||
expect((failure as ApiError).retryAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves void on 204", async () => {
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
await expect(deleteGroup(3)).resolves.toBeUndefined();
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe("/api/groups/3");
|
||||
expect(fetchMock.mock.calls[0]![1]?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
test("unwraps list envelopes", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ groups: [{ id: 1, name: "default", safe_search: false }] }));
|
||||
const groups = await listGroups();
|
||||
expect(groups).toEqual([{ id: 1, name: "default", safe_search: false }]);
|
||||
});
|
||||
|
||||
test("serializes query filters, omitting undefined", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ queries: [], next_before: null }));
|
||||
await getQueries({ limit: 50, blocked: true, domain: "ads.example", before: undefined });
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe("/api/queries?limit=50&blocked=true&domain=ads.example");
|
||||
});
|
||||
|
||||
test("requests with no filters carry no query string", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ queries: [], next_before: null }));
|
||||
await getQueries();
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe("/api/queries");
|
||||
});
|
||||
|
||||
test("wraps and unwraps group sources", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ source_ids: [2, 5] }));
|
||||
const stored = await putGroupSources(4, [5, 2]);
|
||||
expect(stored).toEqual([2, 5]);
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe("/api/groups/4/sources");
|
||||
expect(fetchMock.mock.calls[0]![1]?.body).toBe(JSON.stringify({ source_ids: [5, 2] }));
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
Blocklist,
|
||||
BlocklistEcho,
|
||||
BlocklistInput,
|
||||
Client,
|
||||
ClientEdit,
|
||||
ClientPrefix,
|
||||
ClientPrefixInput,
|
||||
ForwardZone,
|
||||
ForwardZoneInput,
|
||||
Group,
|
||||
GroupInput,
|
||||
Health,
|
||||
LocalRecord,
|
||||
LocalRecordInput,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
LookupResult,
|
||||
PausePost,
|
||||
PauseState,
|
||||
Period,
|
||||
QueriesFilter,
|
||||
QueriesPage,
|
||||
Rule,
|
||||
RuleEcho,
|
||||
RuleInput,
|
||||
SettingsEnvelope,
|
||||
SettingsPatch,
|
||||
SourceStatus,
|
||||
StatsTimeseries,
|
||||
StatsTotals,
|
||||
Upstream,
|
||||
UpstreamEcho,
|
||||
UpstreamHealth,
|
||||
UpstreamInput,
|
||||
Version,
|
||||
} from "@/lib/types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly retryAfter?: number;
|
||||
|
||||
constructor(status: number, message: string, retryAfter?: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
async function toApiError(res: Response): Promise<ApiError> {
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const body: unknown = await res.json();
|
||||
if (typeof body === "object" && body !== null && "error" in body && typeof body.error === "string") {
|
||||
message = body.error;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON error body; keep the status fallback.
|
||||
}
|
||||
let retryAfter: number | undefined;
|
||||
if (res.status === 429) {
|
||||
const header = res.headers.get("Retry-After");
|
||||
const seconds = header === null ? NaN : Number(header);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) retryAfter = seconds;
|
||||
}
|
||||
return new ApiError(res.status, message, retryAfter);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
|
||||
const body = init?.body;
|
||||
const res = await fetch(path, {
|
||||
method: init?.method ?? "GET",
|
||||
credentials: "same-origin",
|
||||
headers: body !== undefined ? { "content-type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw await toApiError(res);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
type QueryValue = string | number | boolean | undefined;
|
||||
|
||||
function qs(params: Record<string, QueryValue>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) search.set(key, String(value));
|
||||
}
|
||||
const encoded = search.toString();
|
||||
return encoded === "" ? "" : `?${encoded}`;
|
||||
}
|
||||
|
||||
// Monitoring + meta
|
||||
|
||||
export const getHealth = (): Promise<Health> => request("/api/health");
|
||||
export const getVersion = (): Promise<Version> => request("/api/version");
|
||||
|
||||
// Auth
|
||||
|
||||
export const login = (body: LoginRequest): Promise<LoginResponse> =>
|
||||
request("/api/auth/login", { method: "POST", body });
|
||||
export const logout = (): Promise<LogoutResponse> => request("/api/auth/logout", { method: "POST", body: {} });
|
||||
|
||||
// Query log + stats
|
||||
|
||||
export const getQueries = (filter: QueriesFilter = {}): Promise<QueriesPage> =>
|
||||
request(`/api/queries${qs({ ...filter })}`);
|
||||
|
||||
/** `EventSource` URL for the live stream; not a fetch route. */
|
||||
export const liveQueriesUrl = "/api/queries/live";
|
||||
|
||||
export const getStats = (period?: Period): Promise<StatsTotals> => request(`/api/stats${qs({ period })}`);
|
||||
export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
|
||||
request(`/api/stats/timeseries${qs({ period })}`);
|
||||
|
||||
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
|
||||
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
|
||||
|
||||
export const getUpstreamHealth = (): Promise<UpstreamHealth> => request("/api/upstream/health");
|
||||
|
||||
// Groups
|
||||
|
||||
export const listGroups = async (): Promise<Group[]> => (await request<{ groups: Group[] }>("/api/groups")).groups;
|
||||
export const createGroup = (input: GroupInput): Promise<Group> =>
|
||||
request("/api/groups", { method: "POST", body: input });
|
||||
export const updateGroup = (id: number, input: GroupInput): Promise<Group> =>
|
||||
request(`/api/groups/${id}`, { method: "PUT", body: input });
|
||||
export const deleteGroup = (id: number): Promise<void> => request(`/api/groups/${id}`, { method: "DELETE" });
|
||||
|
||||
export const getGroupSources = async (id: number): Promise<number[]> =>
|
||||
(await request<{ source_ids: number[] }>(`/api/groups/${id}/sources`)).source_ids;
|
||||
export const putGroupSources = async (id: number, sourceIds: number[]): Promise<number[]> =>
|
||||
(
|
||||
await request<{ source_ids: number[] }>(`/api/groups/${id}/sources`, {
|
||||
method: "PUT",
|
||||
body: { source_ids: sourceIds },
|
||||
})
|
||||
).source_ids;
|
||||
|
||||
// Blocklists
|
||||
|
||||
export const listBlocklists = async (): Promise<Blocklist[]> =>
|
||||
(await request<{ blocklists: Blocklist[] }>("/api/blocklists")).blocklists;
|
||||
export const createBlocklist = (input: BlocklistInput): Promise<BlocklistEcho> =>
|
||||
request("/api/blocklists", { method: "POST", body: input });
|
||||
export const updateBlocklistsNow = async (): Promise<SourceStatus[]> =>
|
||||
(await request<{ sources: SourceStatus[] }>("/api/blocklists/update", { method: "POST", body: {} })).sources;
|
||||
export const updateBlocklist = (id: number, input: BlocklistInput): Promise<BlocklistEcho> =>
|
||||
request(`/api/blocklists/${id}`, { method: "PUT", body: input });
|
||||
export const deleteBlocklist = (id: number): Promise<void> => request(`/api/blocklists/${id}`, { method: "DELETE" });
|
||||
|
||||
// Rules
|
||||
|
||||
export const listRules = async (): Promise<Rule[]> => (await request<{ rules: Rule[] }>("/api/rules")).rules;
|
||||
export const createRule = (input: RuleInput): Promise<RuleEcho> =>
|
||||
request("/api/rules", { method: "POST", body: input });
|
||||
export const updateRule = (id: number, input: RuleInput): Promise<RuleEcho> =>
|
||||
request(`/api/rules/${id}`, { method: "PUT", body: input });
|
||||
export const deleteRule = (id: number): Promise<void> => request(`/api/rules/${id}`, { method: "DELETE" });
|
||||
|
||||
// Local records
|
||||
|
||||
export const listLocalRecords = async (): Promise<LocalRecord[]> =>
|
||||
(await request<{ local_records: LocalRecord[] }>("/api/local-records")).local_records;
|
||||
export const createLocalRecord = (input: LocalRecordInput): Promise<LocalRecord> =>
|
||||
request("/api/local-records", { method: "POST", body: input });
|
||||
export const updateLocalRecord = (id: number, input: LocalRecordInput): Promise<LocalRecord> =>
|
||||
request(`/api/local-records/${id}`, { method: "PUT", body: input });
|
||||
export const deleteLocalRecord = (id: number): Promise<void> =>
|
||||
request(`/api/local-records/${id}`, { method: "DELETE" });
|
||||
|
||||
// Forward zones
|
||||
|
||||
export const listForwardZones = async (): Promise<ForwardZone[]> =>
|
||||
(await request<{ forward_zones: ForwardZone[] }>("/api/forward-zones")).forward_zones;
|
||||
export const createForwardZone = (input: ForwardZoneInput): Promise<ForwardZone> =>
|
||||
request("/api/forward-zones", { method: "POST", body: input });
|
||||
export const updateForwardZone = (id: number, input: ForwardZoneInput): Promise<ForwardZone> =>
|
||||
request(`/api/forward-zones/${id}`, { method: "PUT", body: input });
|
||||
export const deleteForwardZone = (id: number): Promise<void> =>
|
||||
request(`/api/forward-zones/${id}`, { method: "DELETE" });
|
||||
|
||||
// Clients + prefixes
|
||||
|
||||
export const listClients = async (): Promise<Client[]> =>
|
||||
(await request<{ clients: Client[] }>("/api/clients")).clients;
|
||||
export const updateClient = (id: number, edit: ClientEdit): Promise<Client> =>
|
||||
request(`/api/clients/${id}`, { method: "PUT", body: edit });
|
||||
export const deleteClient = (id: number): Promise<void> => request(`/api/clients/${id}`, { method: "DELETE" });
|
||||
|
||||
export const listClientPrefixes = async (): Promise<ClientPrefix[]> =>
|
||||
(await request<{ client_prefixes: ClientPrefix[] }>("/api/client-prefixes")).client_prefixes;
|
||||
export const putClientPrefixes = async (prefixes: ClientPrefixInput[]): Promise<ClientPrefix[]> =>
|
||||
(
|
||||
await request<{ client_prefixes: ClientPrefix[] }>("/api/client-prefixes", {
|
||||
method: "PUT",
|
||||
body: { client_prefixes: prefixes },
|
||||
})
|
||||
).client_prefixes;
|
||||
|
||||
// Upstreams
|
||||
|
||||
export const listUpstreams = async (): Promise<Upstream[]> =>
|
||||
(await request<{ upstreams: Upstream[] }>("/api/upstreams")).upstreams;
|
||||
export const createUpstream = (input: UpstreamInput): Promise<UpstreamEcho> =>
|
||||
request("/api/upstreams", { method: "POST", body: input });
|
||||
export const updateUpstream = (id: number, input: UpstreamInput): Promise<UpstreamEcho> =>
|
||||
request(`/api/upstreams/${id}`, { method: "PUT", body: input });
|
||||
export const deleteUpstream = (id: number): Promise<void> => request(`/api/upstreams/${id}`, { method: "DELETE" });
|
||||
|
||||
// Pause + settings
|
||||
|
||||
export const getPause = (): Promise<PauseState> => request("/api/pause");
|
||||
export const postPause = (body: PausePost): Promise<PauseState> => request("/api/pause", { method: "POST", body });
|
||||
|
||||
export const getSettings = (): Promise<SettingsEnvelope> => request("/api/settings");
|
||||
export const putSettings = (patch: SettingsPatch): Promise<SettingsEnvelope> =>
|
||||
request("/api/settings", { method: "PUT", body: patch });
|
||||
@@ -0,0 +1,717 @@
|
||||
// Generated file — do not edit by hand.
|
||||
//
|
||||
// Every value below is a real response from the web server, captured by the
|
||||
// contract-sample test in src/web/web_integration_test.zig and canonicalized:
|
||||
// object keys sorted, every number 0, strings and booleans as the deterministic
|
||||
// seed produced them, repeated array elements collapsed to the first. The type
|
||||
// annotations are the ones api.ts hands to its own `request<T>`, so `tsc`
|
||||
// refuses a field the wire does not send, a wire field types.ts does not
|
||||
// declare, and a string outside a literal union.
|
||||
//
|
||||
// Regenerate with:
|
||||
// zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
|
||||
|
||||
import type {
|
||||
Blocklist,
|
||||
BlocklistEcho,
|
||||
Client,
|
||||
ClientPrefix,
|
||||
ErrorEnvelope,
|
||||
ForwardZone,
|
||||
Group,
|
||||
Health,
|
||||
LocalRecord,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
LookupResult,
|
||||
PauseState,
|
||||
QueriesPage,
|
||||
Rule,
|
||||
RuleEcho,
|
||||
SettingsEnvelope,
|
||||
SourceStatus,
|
||||
StatsTimeseries,
|
||||
StatsTotals,
|
||||
Upstream,
|
||||
UpstreamEcho,
|
||||
UpstreamHealth,
|
||||
Version,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const sample_get_health: Health = {
|
||||
disk: {
|
||||
db_bytes: 0,
|
||||
free_bytes: 0,
|
||||
log_bytes: 0,
|
||||
sample_failures: 0,
|
||||
state: "ok",
|
||||
},
|
||||
queries_dropped: 0,
|
||||
refreshes_gated: 0,
|
||||
snapshot_generation: 0,
|
||||
status: "ok",
|
||||
upstreams: {
|
||||
available: 0,
|
||||
total: 0,
|
||||
},
|
||||
writer_failed: false,
|
||||
};
|
||||
|
||||
export const sample_get_version: Version = {
|
||||
git_commit: "<build>",
|
||||
uptime_seconds: 0,
|
||||
version: "w10-test",
|
||||
zig_version: "<build>",
|
||||
};
|
||||
|
||||
export const sample_login: LoginResponse = {
|
||||
auth_required: false,
|
||||
authenticated: true,
|
||||
};
|
||||
|
||||
export const sample_logout: LogoutResponse = {
|
||||
authenticated: false,
|
||||
};
|
||||
|
||||
export const sample_create_blocklist: BlocklistEcho = {
|
||||
enabled: false,
|
||||
id: 0,
|
||||
is_suggested: false,
|
||||
name: "ads",
|
||||
url: "https://lists.example/ads.txt",
|
||||
};
|
||||
|
||||
export const sample_list_blocklists: { blocklists: Blocklist[] } = {
|
||||
blocklists: [
|
||||
{
|
||||
checksum: null,
|
||||
domain_count: 0,
|
||||
enabled: false,
|
||||
exception_count: 0,
|
||||
id: 0,
|
||||
is_suggested: false,
|
||||
last_updated: null,
|
||||
name: "ads",
|
||||
skipped_regex_count: 0,
|
||||
skipped_unsupported_count: 0,
|
||||
url: "https://lists.example/ads.txt",
|
||||
wildcard_count: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_blocklist: BlocklistEcho = {
|
||||
enabled: false,
|
||||
id: 0,
|
||||
is_suggested: false,
|
||||
name: "ads2",
|
||||
url: "https://lists.example/ads.txt",
|
||||
};
|
||||
|
||||
export const sample_update_blocklists_now: { sources: SourceStatus[] } = {
|
||||
sources: [
|
||||
{
|
||||
domains: 0,
|
||||
exceptions: 0,
|
||||
id: 0,
|
||||
last_attempt: 0,
|
||||
last_error: "",
|
||||
last_success: 0,
|
||||
loaded: false,
|
||||
skipped_regex: 0,
|
||||
skipped_unsupported: 0,
|
||||
state: "never_fetched",
|
||||
url: "https://lists.example/ads.txt",
|
||||
wildcards: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_list_groups: { groups: Group[] } = {
|
||||
groups: [
|
||||
{
|
||||
id: 0,
|
||||
name: "default",
|
||||
safe_search: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_create_group: Group = {
|
||||
id: 0,
|
||||
name: "kids",
|
||||
safe_search: false,
|
||||
};
|
||||
|
||||
export const sample_update_group: Group = {
|
||||
id: 0,
|
||||
name: "teens",
|
||||
safe_search: true,
|
||||
};
|
||||
|
||||
export const sample_put_group_sources: { source_ids: number[] } = {
|
||||
source_ids: [0],
|
||||
};
|
||||
|
||||
export const sample_get_group_sources: { source_ids: number[] } = {
|
||||
source_ids: [0],
|
||||
};
|
||||
|
||||
export const sample_create_rule: RuleEcho = {
|
||||
action: "block",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
kind: "exact",
|
||||
pattern: "ads.example",
|
||||
};
|
||||
|
||||
export const sample_list_rules: { rules: Rule[] } = {
|
||||
rules: [
|
||||
{
|
||||
action: "block",
|
||||
created_at: 0,
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
kind: "exact",
|
||||
pattern: "ads.example",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_rule: RuleEcho = {
|
||||
action: "block",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
kind: "wildcard",
|
||||
pattern: "*.ads.example",
|
||||
};
|
||||
|
||||
export const sample_get_lookup: LookupResult = {
|
||||
blocked: true,
|
||||
domain: "sub.ads.example",
|
||||
forward_zone: null,
|
||||
group_id: 0,
|
||||
local_records: false,
|
||||
matched: "*.ads.example",
|
||||
reason: "rule_block_wildcard",
|
||||
safe_search_rewrite: null,
|
||||
source_url: null,
|
||||
};
|
||||
|
||||
export const sample_create_local_record: LocalRecord = {
|
||||
id: 0,
|
||||
name: "nas.lan",
|
||||
rtype: "A",
|
||||
ttl: 0,
|
||||
value: "192.168.1.10",
|
||||
};
|
||||
|
||||
export const sample_list_local_records: { local_records: LocalRecord[] } = {
|
||||
local_records: [
|
||||
{
|
||||
id: 0,
|
||||
name: "nas.lan",
|
||||
rtype: "A",
|
||||
ttl: 0,
|
||||
value: "192.168.1.10",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_local_record: LocalRecord = {
|
||||
id: 0,
|
||||
name: "nas.lan",
|
||||
rtype: "A",
|
||||
ttl: 0,
|
||||
value: "192.168.1.11",
|
||||
};
|
||||
|
||||
export const sample_create_forward_zone: ForwardZone = {
|
||||
id: 0,
|
||||
resolver: "udp://10.0.0.1:53",
|
||||
zone: "lan",
|
||||
};
|
||||
|
||||
export const sample_list_forward_zones: { forward_zones: ForwardZone[] } = {
|
||||
forward_zones: [
|
||||
{
|
||||
id: 0,
|
||||
resolver: "udp://10.0.0.1:53",
|
||||
zone: "lan",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_forward_zone: ForwardZone = {
|
||||
id: 0,
|
||||
resolver: "udp://10.0.0.2:53",
|
||||
zone: "lan",
|
||||
};
|
||||
|
||||
export const sample_list_clients: { clients: Client[] } = {
|
||||
clients: [
|
||||
{
|
||||
first_seen: 0,
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
hand_edited: false,
|
||||
id: 0,
|
||||
ip: "192.168.1.50",
|
||||
last_seen: 0,
|
||||
learned_name: "",
|
||||
name: "laptop",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_update_client: Client = {
|
||||
first_seen: 0,
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
hand_edited: true,
|
||||
id: 0,
|
||||
ip: "192.168.1.50",
|
||||
last_seen: 0,
|
||||
learned_name: "",
|
||||
name: "laptop-renamed",
|
||||
};
|
||||
|
||||
export const sample_put_client_prefixes: { client_prefixes: ClientPrefix[] } = {
|
||||
client_prefixes: [
|
||||
{
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
prefix: "192.168.1.0/24",
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_list_client_prefixes: { client_prefixes: ClientPrefix[] } = {
|
||||
client_prefixes: [
|
||||
{
|
||||
group: "default",
|
||||
group_id: 0,
|
||||
id: 0,
|
||||
prefix: "192.168.1.0/24",
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_list_upstreams: { upstreams: Upstream[] } = {
|
||||
upstreams: [
|
||||
{
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
tls_name: "",
|
||||
url: "https://dns.example/dns-query",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_create_upstream: UpstreamEcho = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
restart_required: true,
|
||||
tls_name: "",
|
||||
url: "https://dns2.example/dns-query",
|
||||
};
|
||||
|
||||
export const sample_update_upstream: UpstreamEcho = {
|
||||
enabled: true,
|
||||
id: 0,
|
||||
priority: 0,
|
||||
restart_required: true,
|
||||
tls_name: "",
|
||||
url: "https://dns.example/dns-query",
|
||||
};
|
||||
|
||||
export const sample_get_upstream_health: UpstreamHealth = {
|
||||
available: 0,
|
||||
total: 0,
|
||||
upstreams: [
|
||||
{
|
||||
available: true,
|
||||
consecutive_failures: 0,
|
||||
enabled: true,
|
||||
last_error: "",
|
||||
success_rate: 0,
|
||||
total_failures: 0,
|
||||
total_successes: 0,
|
||||
url: "https://dns.example/dns-query",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_get_queries: QueriesPage = {
|
||||
next_before: 0,
|
||||
queries: [
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: true,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d24.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: false,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d23.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: true,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d22.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "",
|
||||
blocked: false,
|
||||
cache_hit: false,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d21.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "https://dns.example/dns-query",
|
||||
},
|
||||
{
|
||||
block_reason: "blocklist_domain",
|
||||
blocked: true,
|
||||
cache_hit: null,
|
||||
client_ip: "192.0.2.10",
|
||||
domain: "d20.example",
|
||||
id: 0,
|
||||
qtype: 0,
|
||||
response_time_us: 0,
|
||||
ts: 0,
|
||||
upstream: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sample_get_stats: StatsTotals = {
|
||||
avg_response_time_us: null,
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
clients: 0,
|
||||
period: "1h",
|
||||
queries: 0,
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_stats_timeseries: StatsTimeseries = {
|
||||
bucket_seconds: 0,
|
||||
buckets: [
|
||||
{
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
queries: 0,
|
||||
ts: 0,
|
||||
},
|
||||
],
|
||||
period: "1h",
|
||||
since: 0,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_pause: PauseState = {
|
||||
paused: false,
|
||||
until: null,
|
||||
};
|
||||
|
||||
export const sample_post_pause: PauseState = {
|
||||
paused: true,
|
||||
until: 0,
|
||||
};
|
||||
|
||||
export const sample_get_settings: SettingsEnvelope = {
|
||||
authority: {
|
||||
mode: "database",
|
||||
path: null,
|
||||
reconciled_at: null,
|
||||
},
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"dns.rate_limit",
|
||||
"dns.rate_window_seconds",
|
||||
"blocking.response",
|
||||
"blocking.ttl",
|
||||
"cache.size",
|
||||
"cache.negative_ttl_max",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.api_localhost_exempt",
|
||||
"web.sse_max_connections_per_ip",
|
||||
"web.trusted_proxies",
|
||||
"doh_server.enabled",
|
||||
"doh_server.bind",
|
||||
"doh_server.port",
|
||||
"doh_server.cert_path",
|
||||
"doh_server.key_path",
|
||||
"dot_server.enabled",
|
||||
"dot_server.bind",
|
||||
"dot_server.port",
|
||||
"dot_server.cert_path",
|
||||
"dot_server.key_path",
|
||||
"edns.ecs_mode",
|
||||
"logging.level",
|
||||
"logging.retention_days",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.hide_domains",
|
||||
"logging.hide_client_ips",
|
||||
"logging.output",
|
||||
"logging.file_path",
|
||||
"logging.max_size_mb",
|
||||
"logging.max_files",
|
||||
"disk.min_free_mb",
|
||||
"disk.warn_free_mb",
|
||||
"blocklist_update.enabled",
|
||||
"blocklist_update.interval_hours",
|
||||
],
|
||||
settings: {
|
||||
blocking: {
|
||||
response: "zero",
|
||||
ttl: 0,
|
||||
},
|
||||
blocklist_update: {
|
||||
enabled: true,
|
||||
interval_hours: 0,
|
||||
},
|
||||
cache: {
|
||||
negative_ttl_max: 0,
|
||||
size: 0,
|
||||
},
|
||||
disk: {
|
||||
min_free_mb: 0,
|
||||
warn_free_mb: 0,
|
||||
},
|
||||
dns: {
|
||||
bind_ipv4: "0.0.0.0",
|
||||
bind_ipv6: "::",
|
||||
port: 0,
|
||||
rate_limit: 0,
|
||||
rate_window_seconds: 0,
|
||||
},
|
||||
doh_server: {
|
||||
bind: "0.0.0.0",
|
||||
cert_path: "/etc/nxdns/cert.pem",
|
||||
enabled: false,
|
||||
key_path: "/etc/nxdns/key.pem",
|
||||
port: 0,
|
||||
},
|
||||
dot_server: {
|
||||
bind: "0.0.0.0",
|
||||
cert_path: "/etc/nxdns/cert.pem",
|
||||
enabled: false,
|
||||
key_path: "/etc/nxdns/key.pem",
|
||||
port: 0,
|
||||
},
|
||||
edns: {
|
||||
ecs_mode: "strip",
|
||||
},
|
||||
logging: {
|
||||
file_path: "/var/log/nxdns/nxdns.log",
|
||||
hide_client_ips: false,
|
||||
hide_domains: false,
|
||||
level: "info",
|
||||
max_files: 0,
|
||||
max_size_mb: 0,
|
||||
output: "stderr",
|
||||
query_log_buffer_max: 0,
|
||||
retention_days: 0,
|
||||
},
|
||||
upstream: {
|
||||
attempt_timeout_ms: 0,
|
||||
read_timeout_ms: 0,
|
||||
total_timeout_ms: 0,
|
||||
},
|
||||
web: {
|
||||
api_localhost_exempt: true,
|
||||
api_rate_limit_per_min: 0,
|
||||
auth_enabled: false,
|
||||
bind: "0.0.0.0",
|
||||
enabled: true,
|
||||
port: 0,
|
||||
session_ttl_hours: 0,
|
||||
sse_max_connections_per_ip: 0,
|
||||
trusted_proxies: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const sample_put_settings: SettingsEnvelope = {
|
||||
authority: {
|
||||
mode: "database",
|
||||
path: null,
|
||||
reconciled_at: null,
|
||||
},
|
||||
restart_required: [
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"dns.bind_ipv4",
|
||||
"dns.bind_ipv6",
|
||||
"dns.port",
|
||||
"dns.rate_limit",
|
||||
"dns.rate_window_seconds",
|
||||
"blocking.response",
|
||||
"blocking.ttl",
|
||||
"cache.size",
|
||||
"cache.negative_ttl_max",
|
||||
"web.enabled",
|
||||
"web.bind",
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.api_rate_limit_per_min",
|
||||
"web.api_localhost_exempt",
|
||||
"web.sse_max_connections_per_ip",
|
||||
"web.trusted_proxies",
|
||||
"doh_server.enabled",
|
||||
"doh_server.bind",
|
||||
"doh_server.port",
|
||||
"doh_server.cert_path",
|
||||
"doh_server.key_path",
|
||||
"dot_server.enabled",
|
||||
"dot_server.bind",
|
||||
"dot_server.port",
|
||||
"dot_server.cert_path",
|
||||
"dot_server.key_path",
|
||||
"edns.ecs_mode",
|
||||
"logging.level",
|
||||
"logging.retention_days",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.hide_domains",
|
||||
"logging.hide_client_ips",
|
||||
"logging.output",
|
||||
"logging.file_path",
|
||||
"logging.max_size_mb",
|
||||
"logging.max_files",
|
||||
"disk.min_free_mb",
|
||||
"disk.warn_free_mb",
|
||||
"blocklist_update.enabled",
|
||||
"blocklist_update.interval_hours",
|
||||
],
|
||||
settings: {
|
||||
blocking: {
|
||||
response: "zero",
|
||||
ttl: 0,
|
||||
},
|
||||
blocklist_update: {
|
||||
enabled: true,
|
||||
interval_hours: 0,
|
||||
},
|
||||
cache: {
|
||||
negative_ttl_max: 0,
|
||||
size: 0,
|
||||
},
|
||||
disk: {
|
||||
min_free_mb: 0,
|
||||
warn_free_mb: 0,
|
||||
},
|
||||
dns: {
|
||||
bind_ipv4: "0.0.0.0",
|
||||
bind_ipv6: "::",
|
||||
port: 0,
|
||||
rate_limit: 0,
|
||||
rate_window_seconds: 0,
|
||||
},
|
||||
doh_server: {
|
||||
bind: "0.0.0.0",
|
||||
cert_path: "/etc/nxdns/cert.pem",
|
||||
enabled: false,
|
||||
key_path: "/etc/nxdns/key.pem",
|
||||
port: 0,
|
||||
},
|
||||
dot_server: {
|
||||
bind: "0.0.0.0",
|
||||
cert_path: "/etc/nxdns/cert.pem",
|
||||
enabled: false,
|
||||
key_path: "/etc/nxdns/key.pem",
|
||||
port: 0,
|
||||
},
|
||||
edns: {
|
||||
ecs_mode: "strip",
|
||||
},
|
||||
logging: {
|
||||
file_path: "/var/log/nxdns/nxdns.log",
|
||||
hide_client_ips: false,
|
||||
hide_domains: false,
|
||||
level: "info",
|
||||
max_files: 0,
|
||||
max_size_mb: 0,
|
||||
output: "stderr",
|
||||
query_log_buffer_max: 0,
|
||||
retention_days: 0,
|
||||
},
|
||||
upstream: {
|
||||
attempt_timeout_ms: 0,
|
||||
read_timeout_ms: 0,
|
||||
total_timeout_ms: 0,
|
||||
},
|
||||
web: {
|
||||
api_localhost_exempt: true,
|
||||
api_rate_limit_per_min: 0,
|
||||
auth_enabled: false,
|
||||
bind: "0.0.0.0",
|
||||
enabled: true,
|
||||
port: 0,
|
||||
session_ttl_hours: 0,
|
||||
sse_max_connections_per_ip: 0,
|
||||
trusted_proxies: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const sample_error_bad_request: ErrorEnvelope = {
|
||||
error: "logging.level: not one of the values this setting accepts",
|
||||
};
|
||||
|
||||
export const sample_error_conflict: ErrorEnvelope = {
|
||||
error: "an upstream with that url already exists",
|
||||
};
|
||||
|
||||
export const sample_error_not_found: ErrorEnvelope = {
|
||||
error: "not found",
|
||||
};
|
||||
|
||||
export const sample_error_unauthorized: ErrorEnvelope = {
|
||||
error: "authentication required",
|
||||
};
|
||||
|
||||
export const sample_error_rate_limited: ErrorEnvelope = {
|
||||
error: "rate limited",
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Embeds the committed contract-sample golden (milestone-17 ruling 5). Module
|
||||
//! root for the `contract_samples` anonymous import (test builds only) — a
|
||||
//! `.ts` file cannot root one, and @embedFile paths resolve relative to this
|
||||
//! file. The `docs/docs.zig` pattern.
|
||||
|
||||
pub const bytes = @embedFile("contractSamples.gen.ts");
|
||||
|
||||
/// Repo-relative path, so a failing assertion names the file to regenerate.
|
||||
pub const path = "admin/src/lib/contractSamples.gen.ts";
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Group } from "@/lib/types";
|
||||
|
||||
/** The seeded group every client falls back to; the API forbids renaming or deleting it. */
|
||||
export const DEFAULT_GROUP_ID = 1;
|
||||
|
||||
/**
|
||||
* The group a form preselects. The list arrives ordered by name
|
||||
* (groups_repo.zig), so `groups[0]` is the alphabetically first group, not the
|
||||
* default — it is only the fallback for a list that lost the seeded group.
|
||||
*/
|
||||
export function defaultGroupId(groups: readonly Group[]): number {
|
||||
return groups.find((group) => group.id === DEFAULT_GROUP_ID)?.id ?? groups[0]?.id ?? DEFAULT_GROUP_ID;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { formatBytes, formatMicros, 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");
|
||||
});
|
||||
|
||||
test("formatBytes humanizes with binary units", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(1023)).toBe("1023 B");
|
||||
expect(formatBytes(1024)).toBe("1.0 KiB");
|
||||
expect(formatBytes(1536)).toBe("1.5 KiB");
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MiB");
|
||||
expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe("3.0 GiB");
|
||||
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
|
||||
});
|
||||
|
||||
test("formatMicros renders milliseconds with one decimal", () => {
|
||||
expect(formatMicros(0)).toBe("0.0 ms");
|
||||
expect(formatMicros(1234)).toBe("1.2 ms");
|
||||
expect(formatMicros(999)).toBe("1.0 ms");
|
||||
expect(formatMicros(2_500_000)).toBe("2500.0 ms");
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 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, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
timeZone,
|
||||
}).format(new Date(unixSeconds * 1000));
|
||||
}
|
||||
|
||||
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
let value = bytes;
|
||||
let unit: string = BYTE_UNITS[0];
|
||||
for (const next of BYTE_UNITS) {
|
||||
unit = next;
|
||||
value /= 1024;
|
||||
if (value < 1024) break;
|
||||
}
|
||||
return `${value.toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
|
||||
export function formatMicros(micros: number): string {
|
||||
return `${(micros / 1000).toFixed(1)} ms`;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { infiniteQueryOptions, keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
|
||||
import * as api from "@/lib/api";
|
||||
import { setRefreshStatus } from "@/features/blocklists/refreshStore";
|
||||
import type {
|
||||
BlocklistInput,
|
||||
ClientEdit,
|
||||
ClientPrefixInput,
|
||||
ForwardZoneInput,
|
||||
GroupInput,
|
||||
LocalRecordInput,
|
||||
PausePost,
|
||||
Period,
|
||||
QueriesFilter,
|
||||
QueriesPage,
|
||||
RuleInput,
|
||||
SettingsPatch,
|
||||
UpstreamInput,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const queryKeys = {
|
||||
health: ["health"] as const,
|
||||
version: ["version"] as const,
|
||||
stats: (period: Period) => ["stats", period] as const,
|
||||
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
|
||||
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
|
||||
upstreamHealth: ["upstream-health"] as const,
|
||||
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
|
||||
/** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */
|
||||
lookupAll: ["lookup"] as const,
|
||||
groups: ["groups"] as const,
|
||||
groupSources: (id: number) => ["groups", id, "sources"] as const,
|
||||
blocklists: ["blocklists"] as const,
|
||||
rules: ["rules"] as const,
|
||||
localRecords: ["local-records"] as const,
|
||||
forwardZones: ["forward-zones"] as const,
|
||||
clients: ["clients"] as const,
|
||||
clientPrefixes: ["client-prefixes"] as const,
|
||||
upstreams: ["upstreams"] as const,
|
||||
pause: ["pause"] as const,
|
||||
settings: ["settings"] as const,
|
||||
};
|
||||
|
||||
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 });
|
||||
|
||||
export const statsQuery = (period: Period = "24h") =>
|
||||
queryOptions({ queryKey: queryKeys.stats(period), queryFn: () => api.getStats(period), refetchInterval: 30_000 });
|
||||
|
||||
export const timeseriesQuery = (period: Period = "24h") =>
|
||||
queryOptions({
|
||||
queryKey: queryKeys.timeseries(period),
|
||||
queryFn: () => api.getStatsTimeseries(period),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Keyset pagination on `next_before` (handlers/queries.zig). A background
|
||||
// refetch replays every page in cursor order, so newly logged rows shift the
|
||||
// whole window instead of opening a gap between page 1 and page 2.
|
||||
export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: queryKeys.queriesInfinite(filter),
|
||||
queryFn: ({ pageParam }: { pageParam: number | undefined }) =>
|
||||
api.getQueries(pageParam === undefined ? filter : { ...filter, before: pageParam }),
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (last: QueriesPage) => last.next_before ?? undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
export const upstreamHealthQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 });
|
||||
|
||||
export const lookupQuery = (domain: string, groupId?: number) =>
|
||||
queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) });
|
||||
|
||||
export const groupsQuery = () => queryOptions({ queryKey: queryKeys.groups, queryFn: api.listGroups });
|
||||
|
||||
export const groupSourcesQuery = (id: number) =>
|
||||
queryOptions({ queryKey: queryKeys.groupSources(id), queryFn: () => api.getGroupSources(id) });
|
||||
|
||||
export const blocklistsQuery = () => queryOptions({ queryKey: queryKeys.blocklists, queryFn: api.listBlocklists });
|
||||
|
||||
export const rulesQuery = () => queryOptions({ queryKey: queryKeys.rules, queryFn: api.listRules });
|
||||
|
||||
export const localRecordsQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.localRecords, queryFn: api.listLocalRecords });
|
||||
|
||||
export const forwardZonesQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.forwardZones, queryFn: api.listForwardZones });
|
||||
|
||||
export const clientsQuery = () => queryOptions({ queryKey: queryKeys.clients, queryFn: api.listClients });
|
||||
|
||||
export const clientPrefixesQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.clientPrefixes, queryFn: api.listClientPrefixes });
|
||||
|
||||
export const upstreamsQuery = () => queryOptions({ queryKey: queryKeys.upstreams, queryFn: api.listUpstreams });
|
||||
|
||||
export const pauseQuery = () => queryOptions({ queryKey: queryKeys.pause, queryFn: api.getPause });
|
||||
|
||||
export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings, queryFn: api.getSettings });
|
||||
|
||||
// Mutation option factories. Usage: useMutation(groupCreateMutation(useQueryClient())).
|
||||
// Group membership and names feed lookup verdicts and the group columns on
|
||||
// clients, prefixes and rules, hence the wide invalidation on group mutations.
|
||||
|
||||
function invalidateGroupWorld(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.groups }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.clients }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.clientPrefixes }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.rules }),
|
||||
]);
|
||||
}
|
||||
|
||||
export const groupCreateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (input: GroupInput) => api.createGroup(input),
|
||||
onSuccess: () => invalidateGroupWorld(qc),
|
||||
});
|
||||
|
||||
export const groupUpdateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: ({ id, input }: { id: number; input: GroupInput }) => api.updateGroup(id, input),
|
||||
onSuccess: () => invalidateGroupWorld(qc),
|
||||
});
|
||||
|
||||
export const groupDeleteMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (id: number) => api.deleteGroup(id),
|
||||
onSuccess: () => invalidateGroupWorld(qc),
|
||||
});
|
||||
|
||||
export const groupSourcesPutMutation = (qc: QueryClient) => ({
|
||||
mutationFn: ({ id, sourceIds }: { id: number; sourceIds: number[] }) => api.putGroupSources(id, sourceIds),
|
||||
onSuccess: (sourceIds: number[], { id }: { id: number; sourceIds: number[] }) => {
|
||||
qc.setQueryData(queryKeys.groupSources(id), sourceIds);
|
||||
return qc.invalidateQueries({ queryKey: queryKeys.lookupAll });
|
||||
},
|
||||
});
|
||||
|
||||
function invalidateBlocklistWorld(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.groups }),
|
||||
]);
|
||||
}
|
||||
|
||||
export const blocklistCreateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (input: BlocklistInput) => api.createBlocklist(input),
|
||||
onSuccess: () => invalidateBlocklistWorld(qc),
|
||||
});
|
||||
|
||||
export const blocklistUpdateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: ({ id, input }: { id: number; input: BlocklistInput }) => api.updateBlocklist(id, input),
|
||||
onSuccess: () => invalidateBlocklistWorld(qc),
|
||||
});
|
||||
|
||||
export const blocklistDeleteMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (id: number) => api.deleteBlocklist(id),
|
||||
onSuccess: () => invalidateBlocklistWorld(qc),
|
||||
});
|
||||
|
||||
/** Ruling 12: the 202 snapshot REPLACES the refresh store; counters refresh. */
|
||||
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
|
||||
mutationFn: () => api.updateBlocklistsNow(),
|
||||
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
|
||||
setRefreshStatus(sources);
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
function invalidateRules(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.rules }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
|
||||
]);
|
||||
}
|
||||
|
||||
export const ruleCreateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (input: RuleInput) => api.createRule(input),
|
||||
onSuccess: () => invalidateRules(qc),
|
||||
});
|
||||
|
||||
export const ruleDeleteMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (id: number) => api.deleteRule(id),
|
||||
onSuccess: () => invalidateRules(qc),
|
||||
});
|
||||
|
||||
function invalidateLocalRecords(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.localRecords }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
|
||||
]);
|
||||
}
|
||||
|
||||
export const localRecordCreateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (input: LocalRecordInput) => api.createLocalRecord(input),
|
||||
onSuccess: () => invalidateLocalRecords(qc),
|
||||
});
|
||||
|
||||
export const localRecordUpdateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: ({ id, input }: { id: number; input: LocalRecordInput }) => api.updateLocalRecord(id, input),
|
||||
onSuccess: () => invalidateLocalRecords(qc),
|
||||
});
|
||||
|
||||
export const localRecordDeleteMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (id: number) => api.deleteLocalRecord(id),
|
||||
onSuccess: () => invalidateLocalRecords(qc),
|
||||
});
|
||||
|
||||
function invalidateForwardZones(qc: QueryClient): Promise<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.forwardZones }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lookupAll }),
|
||||
]);
|
||||
}
|
||||
|
||||
export const forwardZoneCreateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (input: ForwardZoneInput) => api.createForwardZone(input),
|
||||
onSuccess: () => invalidateForwardZones(qc),
|
||||
});
|
||||
|
||||
export const forwardZoneUpdateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: ({ id, input }: { id: number; input: ForwardZoneInput }) => api.updateForwardZone(id, input),
|
||||
onSuccess: () => invalidateForwardZones(qc),
|
||||
});
|
||||
|
||||
export const forwardZoneDeleteMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (id: number) => api.deleteForwardZone(id),
|
||||
onSuccess: () => invalidateForwardZones(qc),
|
||||
});
|
||||
|
||||
export const clientUpdateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: ({ id, edit }: { id: number; edit: ClientEdit }) => api.updateClient(id, edit),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.clients }),
|
||||
});
|
||||
|
||||
export const clientDeleteMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (id: number) => api.deleteClient(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.clients }),
|
||||
});
|
||||
|
||||
export const clientPrefixesPutMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (prefixes: ClientPrefixInput[]) => api.putClientPrefixes(prefixes),
|
||||
onSuccess: (stored: Awaited<ReturnType<typeof api.putClientPrefixes>>) => {
|
||||
qc.setQueryData(queryKeys.clientPrefixes, stored);
|
||||
},
|
||||
});
|
||||
|
||||
function invalidateUpstreams(qc: QueryClient): Promise<unknown> {
|
||||
return qc.invalidateQueries({ queryKey: queryKeys.upstreams });
|
||||
}
|
||||
|
||||
export const upstreamCreateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (input: UpstreamInput) => api.createUpstream(input),
|
||||
onSuccess: () => invalidateUpstreams(qc),
|
||||
});
|
||||
|
||||
export const upstreamUpdateMutation = (qc: QueryClient) => ({
|
||||
mutationFn: ({ id, input }: { id: number; input: UpstreamInput }) => api.updateUpstream(id, input),
|
||||
onSuccess: () => invalidateUpstreams(qc),
|
||||
});
|
||||
|
||||
export const upstreamDeleteMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (id: number) => api.deleteUpstream(id),
|
||||
onSuccess: () => invalidateUpstreams(qc),
|
||||
});
|
||||
|
||||
export const pauseMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (body: PausePost) => api.postPause(body),
|
||||
onSuccess: (state: Awaited<ReturnType<typeof api.postPause>>) => {
|
||||
qc.setQueryData(queryKeys.pause, state);
|
||||
},
|
||||
});
|
||||
|
||||
export const settingsPutMutation = (qc: QueryClient) => ({
|
||||
mutationFn: (patch: SettingsPatch) => api.putSettings(patch),
|
||||
onSuccess: (envelope: Awaited<ReturnType<typeof api.putSettings>>) => {
|
||||
qc.setQueryData(queryKeys.settings, envelope);
|
||||
return qc.invalidateQueries({ queryKey: queryKeys.settings });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { rememberAuthRequired } from "@/auth/store";
|
||||
|
||||
export function handleUnauthorized(error: unknown): void {
|
||||
if (!(error instanceof ApiError) || error.status !== 401) return;
|
||||
if (window.location.pathname === "/login") return;
|
||||
rememberAuthRequired(true);
|
||||
const current = window.location.pathname + window.location.search;
|
||||
window.location.assign(`/login?redirect=${encodeURIComponent(current)}`);
|
||||
}
|
||||
|
||||
function shouldRetry(failureCount: number, error: unknown): boolean {
|
||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 429) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
}
|
||||
|
||||
function retryDelay(attemptIndex: number, error: unknown): number {
|
||||
if (error instanceof ApiError && error.status === 429 && error.retryAfter !== undefined) {
|
||||
return error.retryAfter * 1000;
|
||||
}
|
||||
return Math.min(1000 * 2 ** attemptIndex, 30_000);
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
queryCache: new QueryCache({ onError: handleUnauthorized }),
|
||||
mutationCache: new MutationCache({ onError: handleUnauthorized }),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: shouldRetry,
|
||||
retryDelay,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||
import type { Settings } from "@/lib/types";
|
||||
|
||||
function baseSettings(): Settings {
|
||||
return {
|
||||
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
||||
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
|
||||
blocking: { response: "zero", ttl: 300 },
|
||||
cache: { size: 10000, negative_ttl_max: 300 },
|
||||
web: {
|
||||
enabled: true,
|
||||
bind: "127.0.0.1",
|
||||
port: 8080,
|
||||
session_ttl_hours: 24,
|
||||
api_rate_limit_per_min: 60,
|
||||
api_localhost_exempt: true,
|
||||
sse_max_connections_per_ip: 2,
|
||||
trusted_proxies: "",
|
||||
auth_enabled: true,
|
||||
},
|
||||
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
||||
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
||||
edns: { ecs_mode: "strip" },
|
||||
logging: {
|
||||
level: "info",
|
||||
retention_days: 30,
|
||||
query_log_buffer_max: 10000,
|
||||
hide_domains: false,
|
||||
hide_client_ips: false,
|
||||
output: "stderr",
|
||||
file_path: "",
|
||||
max_size_mb: 50,
|
||||
max_files: 3,
|
||||
},
|
||||
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
||||
blocklist_update: { enabled: true, interval_hours: 24 },
|
||||
};
|
||||
}
|
||||
|
||||
function edit(mutate: (s: Settings) => void): Settings {
|
||||
const edited = structuredClone(baseSettings());
|
||||
mutate(edited);
|
||||
return edited;
|
||||
}
|
||||
|
||||
test("no changes and no password produces null", () => {
|
||||
expect(buildSettingsPatch(baseSettings(), baseSettings())).toBeNull();
|
||||
});
|
||||
|
||||
test("an empty password is not a change", () => {
|
||||
expect(buildSettingsPatch(baseSettings(), baseSettings(), "")).toBeNull();
|
||||
});
|
||||
|
||||
test("a single scalar change patches only its section field", () => {
|
||||
const edited = edit((s) => {
|
||||
s.dns.port = 5353;
|
||||
});
|
||||
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ dns: { port: 5353 } });
|
||||
});
|
||||
|
||||
test("changes across sections stay grouped and minimal", () => {
|
||||
const edited = edit((s) => {
|
||||
s.logging.level = "debug";
|
||||
s.logging.retention_days = 7;
|
||||
s.cache.size = 20000;
|
||||
});
|
||||
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({
|
||||
cache: { size: 20000 },
|
||||
logging: { level: "debug", retention_days: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
test("tls listener sections diff like any other", () => {
|
||||
const edited = edit((s) => {
|
||||
s.dot_server.enabled = true;
|
||||
s.dot_server.cert_path = "/etc/nxdns/dot.pem";
|
||||
});
|
||||
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({
|
||||
dot_server: { enabled: true, cert_path: "/etc/nxdns/dot.pem" },
|
||||
});
|
||||
});
|
||||
|
||||
test("web.auth_enabled is never emitted even when it differs", () => {
|
||||
const edited = edit((s) => {
|
||||
s.web.auth_enabled = false;
|
||||
s.web.port = 9090;
|
||||
});
|
||||
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ web: { port: 9090 } });
|
||||
});
|
||||
|
||||
test("a password alone produces a web-only patch", () => {
|
||||
expect(buildSettingsPatch(baseSettings(), baseSettings(), "hunter2")).toEqual({
|
||||
web: { password: "hunter2" },
|
||||
});
|
||||
});
|
||||
|
||||
test("a password merges into an existing web section diff", () => {
|
||||
const edited = edit((s) => {
|
||||
s.web.session_ttl_hours = 48;
|
||||
});
|
||||
expect(buildSettingsPatch(baseSettings(), edited, "hunter2")).toEqual({
|
||||
web: { session_ttl_hours: 48, password: "hunter2" },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||
|
||||
const SECTIONS = [
|
||||
"upstream",
|
||||
"dns",
|
||||
"blocking",
|
||||
"cache",
|
||||
"web",
|
||||
"doh_server",
|
||||
"dot_server",
|
||||
"edns",
|
||||
"logging",
|
||||
"disk",
|
||||
"blocklist_update",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Minimal partial patch for PUT /api/settings: only fields whose edited value
|
||||
* differs from the original, grouped by section. The derived `web.auth_enabled`
|
||||
* is never emitted. A non-empty `password` passes through as `web.password`.
|
||||
* Returns null when nothing changed and no password was given.
|
||||
*/
|
||||
export function buildSettingsPatch(original: Settings, edited: Settings, password?: string): SettingsPatch | null {
|
||||
const patch: Record<string, Record<string, unknown>> = {};
|
||||
for (const section of SECTIONS) {
|
||||
const before = original[section] as Record<string, unknown>;
|
||||
const after = edited[section] as Record<string, unknown>;
|
||||
let changed: Record<string, unknown> | undefined;
|
||||
for (const key of Object.keys(after)) {
|
||||
if (section === "web" && key === "auth_enabled") continue;
|
||||
if (before[key] !== after[key]) (changed ??= {})[key] = after[key];
|
||||
}
|
||||
if (changed !== undefined) patch[section] = changed;
|
||||
}
|
||||
if (password !== undefined && password !== "") {
|
||||
patch["web"] = { ...patch["web"], password };
|
||||
}
|
||||
return Object.keys(patch).length === 0 ? null : (patch as SettingsPatch);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// Hand-transcribed from src/web/openapi.yaml. Field names stay snake_case to
|
||||
// match the wire format exactly; nullability mirrors the contract.
|
||||
|
||||
export type Period = "1h" | "24h" | "7d" | "30d";
|
||||
|
||||
/**
|
||||
* Every non-2xx JSON response: `http_util.respondError` writes this one field
|
||||
* and nothing else. `ApiError.message` in api.ts reads `error` out of it.
|
||||
*/
|
||||
export interface ErrorEnvelope {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface Health {
|
||||
status: "ok" | "degraded";
|
||||
disk: {
|
||||
state: "ok" | "warn" | "critical";
|
||||
free_bytes: number;
|
||||
db_bytes: number;
|
||||
log_bytes: number;
|
||||
sample_failures: number;
|
||||
};
|
||||
upstreams: {
|
||||
available: number;
|
||||
total: number;
|
||||
};
|
||||
queries_dropped: number;
|
||||
writer_failed: boolean;
|
||||
refreshes_gated: number;
|
||||
snapshot_generation: number | null;
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
version: string;
|
||||
git_commit: string;
|
||||
zig_version: string;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
authenticated: true;
|
||||
auth_required: boolean;
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
authenticated: false;
|
||||
}
|
||||
|
||||
export interface QueryRow {
|
||||
id: number;
|
||||
ts: number;
|
||||
domain: string;
|
||||
client_ip: string;
|
||||
qtype: number | null;
|
||||
blocked: boolean;
|
||||
block_reason: string;
|
||||
response_time_us: number | null;
|
||||
cache_hit: boolean | null;
|
||||
upstream: string;
|
||||
}
|
||||
|
||||
/** SSE `event: query` payload: a QueryRow minus `id` (precedes persistence). */
|
||||
export type LiveQueryEvent = Omit<QueryRow, "id">;
|
||||
|
||||
export interface QueriesPage {
|
||||
queries: QueryRow[];
|
||||
next_before: number | null;
|
||||
}
|
||||
|
||||
export interface QueriesFilter {
|
||||
limit?: number;
|
||||
before?: number;
|
||||
domain?: string;
|
||||
client?: string;
|
||||
blocked?: boolean;
|
||||
since?: number;
|
||||
until?: number;
|
||||
}
|
||||
|
||||
export interface StatsTotals {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
queries: number;
|
||||
blocked: number;
|
||||
cached: number;
|
||||
clients: number;
|
||||
avg_response_time_us: number | null;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
ts: number;
|
||||
queries: number;
|
||||
blocked: number;
|
||||
cached: number;
|
||||
}
|
||||
|
||||
export interface StatsTimeseries {
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
bucket_seconds: number;
|
||||
buckets: Bucket[];
|
||||
}
|
||||
|
||||
export interface LookupResult {
|
||||
domain: string;
|
||||
group_id: number;
|
||||
local_records: boolean;
|
||||
forward_zone: string | null;
|
||||
blocked: boolean;
|
||||
reason: string;
|
||||
matched: string;
|
||||
source_url: string | null;
|
||||
safe_search_rewrite: string | null;
|
||||
}
|
||||
|
||||
export interface UpstreamHealthEntry {
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
consecutive_failures: number;
|
||||
total_successes: number;
|
||||
total_failures: number;
|
||||
success_rate: number;
|
||||
last_error: string;
|
||||
}
|
||||
|
||||
export interface UpstreamHealth {
|
||||
upstreams: UpstreamHealthEntry[];
|
||||
available: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
safe_search: boolean;
|
||||
}
|
||||
|
||||
export interface GroupInput {
|
||||
name: string;
|
||||
safe_search?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupSources {
|
||||
source_ids: number[];
|
||||
}
|
||||
|
||||
export interface Blocklist {
|
||||
id: number;
|
||||
url: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
is_suggested: boolean;
|
||||
last_updated: number | null;
|
||||
domain_count: number;
|
||||
wildcard_count: number;
|
||||
exception_count: number;
|
||||
skipped_regex_count: number;
|
||||
skipped_unsupported_count: number;
|
||||
checksum: string | null;
|
||||
}
|
||||
|
||||
export interface BlocklistInput {
|
||||
url: string;
|
||||
name: string;
|
||||
enabled?: boolean;
|
||||
is_suggested?: boolean;
|
||||
}
|
||||
|
||||
export interface BlocklistEcho {
|
||||
id: number;
|
||||
url: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
is_suggested: boolean;
|
||||
}
|
||||
|
||||
export interface SourceStatus {
|
||||
id: number;
|
||||
state: string;
|
||||
loaded: boolean;
|
||||
last_attempt: number;
|
||||
last_success: number;
|
||||
url: string;
|
||||
last_error: string;
|
||||
domains: number;
|
||||
wildcards: number;
|
||||
exceptions: number;
|
||||
skipped_regex: number;
|
||||
skipped_unsupported: number;
|
||||
}
|
||||
|
||||
export type RuleKind = "exact" | "wildcard" | "regex";
|
||||
export type RuleAction = "allow" | "block";
|
||||
|
||||
export interface Rule {
|
||||
id: number;
|
||||
group_id: number;
|
||||
group: string;
|
||||
pattern: string;
|
||||
kind: RuleKind;
|
||||
action: RuleAction;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface RuleInput {
|
||||
group_id: number;
|
||||
pattern: string;
|
||||
kind: RuleKind;
|
||||
action: RuleAction;
|
||||
}
|
||||
|
||||
export interface RuleEcho {
|
||||
id: number;
|
||||
group_id: number;
|
||||
pattern: string;
|
||||
kind: RuleKind;
|
||||
action: RuleAction;
|
||||
}
|
||||
|
||||
export type LocalRecordType = "A" | "AAAA" | "CNAME";
|
||||
|
||||
export interface LocalRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
rtype: LocalRecordType;
|
||||
value: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export interface LocalRecordInput {
|
||||
name: string;
|
||||
rtype: LocalRecordType;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export interface ForwardZone {
|
||||
id: number;
|
||||
zone: string;
|
||||
resolver: string;
|
||||
}
|
||||
|
||||
export interface ForwardZoneInput {
|
||||
zone: string;
|
||||
resolver: string;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: number;
|
||||
ip: string;
|
||||
name: string;
|
||||
/** Learned over reverse DNS. `name` wins whenever it is non-empty. */
|
||||
learned_name: string;
|
||||
group_id: number;
|
||||
group: string;
|
||||
hand_edited: boolean;
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
}
|
||||
|
||||
export interface ClientEdit {
|
||||
name?: string;
|
||||
group_id: number;
|
||||
}
|
||||
|
||||
export interface ClientPrefix {
|
||||
id: number;
|
||||
prefix: string;
|
||||
group_id: number;
|
||||
group: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface ClientPrefixInput {
|
||||
prefix: string;
|
||||
group_id: number;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface Upstream {
|
||||
id: number;
|
||||
url: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
tls_name: string;
|
||||
}
|
||||
|
||||
export interface UpstreamInput {
|
||||
url: string;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
tls_name?: string;
|
||||
}
|
||||
|
||||
export interface UpstreamEcho {
|
||||
id: number;
|
||||
url: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
tls_name: string;
|
||||
restart_required: true;
|
||||
}
|
||||
|
||||
export interface PauseState {
|
||||
paused: boolean;
|
||||
until: number | null;
|
||||
}
|
||||
|
||||
export interface PausePost {
|
||||
paused: boolean;
|
||||
duration_seconds?: number | null;
|
||||
}
|
||||
|
||||
export interface TlsListenerSettings {
|
||||
enabled: boolean;
|
||||
bind: string;
|
||||
port: number;
|
||||
cert_path: string;
|
||||
key_path: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
upstream: {
|
||||
attempt_timeout_ms: number;
|
||||
read_timeout_ms: number;
|
||||
total_timeout_ms: number;
|
||||
};
|
||||
dns: {
|
||||
bind_ipv4: string;
|
||||
bind_ipv6: string;
|
||||
port: number;
|
||||
rate_limit: number;
|
||||
rate_window_seconds: number;
|
||||
};
|
||||
blocking: {
|
||||
response: "zero" | "nxdomain";
|
||||
ttl: number;
|
||||
};
|
||||
cache: {
|
||||
size: number;
|
||||
negative_ttl_max: number;
|
||||
};
|
||||
web: {
|
||||
enabled: boolean;
|
||||
bind: string;
|
||||
port: number;
|
||||
session_ttl_hours: number;
|
||||
api_rate_limit_per_min: number;
|
||||
api_localhost_exempt: boolean;
|
||||
sse_max_connections_per_ip: number;
|
||||
/** Comma-separated IP literals; empty trusts no proxy's X-Forwarded-For. */
|
||||
trusted_proxies: string;
|
||||
/** Derived, read-only; true iff a password hash is stored. Never sent back. */
|
||||
auth_enabled: boolean;
|
||||
};
|
||||
doh_server: TlsListenerSettings;
|
||||
dot_server: TlsListenerSettings;
|
||||
edns: {
|
||||
ecs_mode: "strip" | "forward";
|
||||
};
|
||||
logging: {
|
||||
level: "error" | "warn" | "info" | "debug";
|
||||
retention_days: number;
|
||||
query_log_buffer_max: number;
|
||||
hide_domains: boolean;
|
||||
hide_client_ips: boolean;
|
||||
output: "stderr" | "syslog" | "file";
|
||||
file_path: string;
|
||||
max_size_mb: number;
|
||||
max_files: number;
|
||||
};
|
||||
disk: {
|
||||
min_free_mb: number;
|
||||
warn_free_mb: number;
|
||||
};
|
||||
blocklist_update: {
|
||||
enabled: boolean;
|
||||
interval_hours: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which configuration source the running process obeys. `path` and
|
||||
* `reconciled_at` are non-null only under `managed_file`: the file the process
|
||||
* loaded, and the epoch second at which it loaded it. Authority lives in the
|
||||
* invocation, never in the database, so this is the only place the UI can read
|
||||
* it — and it rides an authenticated route, never the open ones.
|
||||
*/
|
||||
export interface Authority {
|
||||
mode: "database" | "managed_file";
|
||||
path: string | null;
|
||||
reconciled_at: number | null;
|
||||
}
|
||||
|
||||
export interface SettingsEnvelope {
|
||||
settings: Settings;
|
||||
restart_required: string[];
|
||||
authority: Authority;
|
||||
}
|
||||
|
||||
export interface TlsListenerPatch {
|
||||
enabled?: boolean;
|
||||
bind?: string;
|
||||
port?: number;
|
||||
cert_path?: string;
|
||||
key_path?: string;
|
||||
}
|
||||
|
||||
/** Partial update; `web.password` is write-only, `web.auth_enabled` is never sent. */
|
||||
export interface SettingsPatch {
|
||||
upstream?: Partial<Settings["upstream"]>;
|
||||
dns?: Partial<Settings["dns"]>;
|
||||
blocking?: Partial<Settings["blocking"]>;
|
||||
cache?: Partial<Settings["cache"]>;
|
||||
web?: Partial<Omit<Settings["web"], "auth_enabled">> & { password?: string };
|
||||
doh_server?: TlsListenerPatch;
|
||||
dot_server?: TlsListenerPatch;
|
||||
edns?: Partial<Settings["edns"]>;
|
||||
logging?: Partial<Settings["logging"]>;
|
||||
disk?: Partial<Settings["disk"]>;
|
||||
blocklist_update?: Partial<Settings["blocklist_update"]>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) throw new Error("missing #root element");
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(undefined, queryClient);
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
createRoute,
|
||||
createRouter,
|
||||
lazyRouteComponent,
|
||||
useRouter,
|
||||
type ErrorComponentProps,
|
||||
type RouterHistory,
|
||||
} from "@tanstack/react-router";
|
||||
import AppShell from "@/shell/AppShell";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import {
|
||||
blocklistsQuery,
|
||||
clientPrefixesQuery,
|
||||
clientsQuery,
|
||||
forwardZonesQuery,
|
||||
groupsQuery,
|
||||
healthQuery,
|
||||
localRecordsQuery,
|
||||
queriesInfiniteQuery,
|
||||
rulesQuery,
|
||||
settingsQuery,
|
||||
statsQuery,
|
||||
timeseriesQuery,
|
||||
upstreamHealthQuery,
|
||||
upstreamsQuery,
|
||||
} from "@/lib/queries";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
export interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
pending: {
|
||||
padding: "2rem",
|
||||
textAlign: "center",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
errorBox: {
|
||||
margin: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.dangerBorder,
|
||||
backgroundColor: colors.dangerSurface,
|
||||
padding: "1rem",
|
||||
},
|
||||
errorTitle: {
|
||||
fontWeight: 600,
|
||||
color: colors.dangerText,
|
||||
},
|
||||
errorDetail: {
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
},
|
||||
});
|
||||
|
||||
function RoutePending() {
|
||||
return (
|
||||
<div {...stylex.props(styles.pending)} role="status">
|
||||
<span {...stylex.props(shared.pulse)}>Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteError({ error }: ErrorComponentProps) {
|
||||
const router = useRouter();
|
||||
let title = "Something went wrong";
|
||||
let detail = error.message;
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 503) {
|
||||
title = "Server starting or degraded";
|
||||
detail = error.message;
|
||||
} else if (error.status === 429) {
|
||||
title = "Rate limited";
|
||||
detail = error.retryAfter !== undefined ? `Try again in ${error.retryAfter}s.` : "Try again shortly.";
|
||||
} else if (error.status >= 500) {
|
||||
title = "Internal error";
|
||||
} else {
|
||||
title = `Request failed (${error.status})`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div role="alert" {...stylex.props(styles.errorBox)}>
|
||||
<h2 {...stylex.props(styles.errorTitle)}>{title}</h2>
|
||||
<p {...stylex.props(styles.errorDetail)}>{detail}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void router.invalidate()}
|
||||
{...stylex.props(shared.retryButton, shared.focusRing)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rootRoute = createRootRouteWithContext<RouterContext>()();
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/login",
|
||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } => ({
|
||||
redirect: typeof search["redirect"] === "string" ? search["redirect"] : undefined,
|
||||
}),
|
||||
component: lazyRouteComponent(() => import("@/auth/LoginPage")),
|
||||
});
|
||||
|
||||
const shellRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: "shell",
|
||||
component: AppShell,
|
||||
});
|
||||
|
||||
const dashboardRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/",
|
||||
// allSettled, not all: DashboardPage reads these with useQuery so each widget
|
||||
// can render its own error. A rejecting loader would replace the whole page
|
||||
// with RouteError and take the three healthy widgets down with the failed one.
|
||||
loader: ({ context }) =>
|
||||
Promise.allSettled([
|
||||
context.queryClient.ensureQueryData(statsQuery("24h")),
|
||||
context.queryClient.ensureQueryData(timeseriesQuery("24h")),
|
||||
context.queryClient.ensureQueryData(healthQuery()),
|
||||
context.queryClient.ensureQueryData(upstreamHealthQuery()),
|
||||
]),
|
||||
component: lazyRouteComponent(() => import("@/features/dashboard/DashboardPage")),
|
||||
});
|
||||
|
||||
const queriesRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/queries",
|
||||
loader: ({ context }) => context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery({})),
|
||||
component: lazyRouteComponent(() => import("@/features/queries/QueryLogPage")),
|
||||
});
|
||||
|
||||
const liveRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/live",
|
||||
component: lazyRouteComponent(() => import("@/features/live/LiveLogPage")),
|
||||
});
|
||||
|
||||
const clientsRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/clients",
|
||||
loader: ({ context }) =>
|
||||
Promise.all([
|
||||
context.queryClient.ensureQueryData(clientsQuery()),
|
||||
context.queryClient.ensureQueryData(clientPrefixesQuery()),
|
||||
context.queryClient.ensureQueryData(groupsQuery()),
|
||||
]),
|
||||
component: lazyRouteComponent(() => import("@/features/clients/ClientsPage")),
|
||||
});
|
||||
|
||||
const groupsRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/groups",
|
||||
loader: ({ context }) =>
|
||||
Promise.all([
|
||||
context.queryClient.ensureQueryData(groupsQuery()),
|
||||
context.queryClient.ensureQueryData(blocklistsQuery()),
|
||||
]),
|
||||
component: lazyRouteComponent(() => import("@/features/groups/GroupsPage")),
|
||||
});
|
||||
|
||||
const blocklistsRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/blocklists",
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(blocklistsQuery()),
|
||||
component: lazyRouteComponent(() => import("@/features/blocklists/BlocklistsPage")),
|
||||
});
|
||||
|
||||
const rulesRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/rules",
|
||||
loader: ({ context }) =>
|
||||
Promise.all([
|
||||
context.queryClient.ensureQueryData(rulesQuery()),
|
||||
context.queryClient.ensureQueryData(groupsQuery()),
|
||||
]),
|
||||
component: lazyRouteComponent(() => import("@/features/rules/RulesPage")),
|
||||
});
|
||||
|
||||
const localDnsRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/local-dns",
|
||||
loader: ({ context }) =>
|
||||
Promise.all([
|
||||
context.queryClient.ensureQueryData(localRecordsQuery()),
|
||||
context.queryClient.ensureQueryData(forwardZonesQuery()),
|
||||
]),
|
||||
component: lazyRouteComponent(() => import("@/features/local/LocalDnsPage")),
|
||||
});
|
||||
|
||||
const upstreamsRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/upstreams",
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(upstreamsQuery()),
|
||||
component: lazyRouteComponent(() => import("@/features/upstreams/UpstreamsPage")),
|
||||
});
|
||||
|
||||
const lookupRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/lookup",
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
|
||||
component: lazyRouteComponent(() => import("@/features/lookup/LookupPage")),
|
||||
});
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/settings",
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(settingsQuery()),
|
||||
component: lazyRouteComponent(() => import("@/features/settings/SettingsPage")),
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
shellRoute.addChildren([
|
||||
dashboardRoute,
|
||||
queriesRoute,
|
||||
liveRoute,
|
||||
clientsRoute,
|
||||
groupsRoute,
|
||||
blocklistsRoute,
|
||||
rulesRoute,
|
||||
localDnsRoute,
|
||||
upstreamsRoute,
|
||||
lookupRoute,
|
||||
settingsRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
export function createAppRouter(history?: RouterHistory, queryClient: QueryClient = createQueryClient()) {
|
||||
return createRouter({
|
||||
routeTree,
|
||||
history,
|
||||
context: { queryClient },
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 0,
|
||||
defaultPendingComponent: RoutePending,
|
||||
defaultErrorComponent: RouteError,
|
||||
});
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: ReturnType<typeof createAppRouter>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider, resetAuthProbeForTests } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
const NAV_LABELS = [
|
||||
"Dashboard",
|
||||
"Query Log",
|
||||
"Live",
|
||||
"Clients",
|
||||
"Groups",
|
||||
"Blocklists",
|
||||
"Rules",
|
||||
"Local DNS",
|
||||
"Upstreams",
|
||||
"Lookup",
|
||||
"Settings",
|
||||
];
|
||||
|
||||
const RESPONSES: Record<string, unknown> = {
|
||||
"/api/stats?period=24h": {
|
||||
period: "24h",
|
||||
since: 0,
|
||||
until: 86400,
|
||||
queries: 0,
|
||||
blocked: 0,
|
||||
cached: 0,
|
||||
clients: 0,
|
||||
avg_response_time_us: null,
|
||||
},
|
||||
"/api/stats/timeseries?period=24h": { period: "24h", since: 0, until: 86400, bucket_seconds: 1800, buckets: [] },
|
||||
"/api/health": {
|
||||
status: "ok",
|
||||
disk: { state: "ok", free_bytes: 0, db_bytes: 0, log_bytes: 0, sample_failures: 0 },
|
||||
upstreams: { available: 1, total: 1 },
|
||||
queries_dropped: 0,
|
||||
writer_failed: false,
|
||||
refreshes_gated: 0,
|
||||
snapshot_generation: null,
|
||||
},
|
||||
"/api/upstream/health": { upstreams: [], available: 1, total: 1 },
|
||||
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
resetAuthProbeForTests();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const payload = RESPONSES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("shell renders the dashboard route with all nav links", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
await screen.findByRole("heading", { name: "Dashboard" });
|
||||
|
||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||
expect(nav).toBeTruthy();
|
||||
for (const label of NAV_LABELS) {
|
||||
expect(screen.getByRole("link", { name: label })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/auth/login")
|
||||
return new Response(JSON.stringify({ error: "password required" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
if (url === "/api/auth/logout")
|
||||
return new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "retry-after": "7" },
|
||||
});
|
||||
const payload = RESPONSES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
||||
|
||||
await screen.findByText("Rate limited. Try again in 7s.");
|
||||
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useAuth } from "@/auth/store";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { versionQuery } from "@/lib/queries";
|
||||
import PauseWidget from "../features/pause/PauseWidget";
|
||||
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
|
||||
import RestartBanner from "../features/settings/RestartBanner";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } 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 NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard" },
|
||||
{ to: "/queries", label: "Query Log" },
|
||||
{ to: "/live", label: "Live" },
|
||||
{ to: "/clients", label: "Clients" },
|
||||
{ to: "/groups", label: "Groups" },
|
||||
{ to: "/blocklists", label: "Blocklists" },
|
||||
{ to: "/rules", label: "Rules" },
|
||||
{ to: "/local-dns", label: "Local DNS" },
|
||||
{ to: "/upstreams", label: "Upstreams" },
|
||||
{ to: "/lookup", label: "Lookup" },
|
||||
{ to: "/settings", label: "Settings" },
|
||||
] as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
navList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
navLink: {
|
||||
display: "block",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
/** The current page reads as a filled chip, heavier than the hover fill. */
|
||||
navActive: {
|
||||
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||
color: colors.text,
|
||||
fontWeight: 500,
|
||||
},
|
||||
navIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||
},
|
||||
versionFooter: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
logoutRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
shell: {
|
||||
minHeight: "100dvh",
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
display: { default: "block", [WIDE]: "grid" },
|
||||
gridTemplateColumns: { default: null, [WIDE]: "14rem 1fr" },
|
||||
},
|
||||
sidebar: {
|
||||
display: { default: "none", [WIDE]: "flex" },
|
||||
flexDirection: { default: null, [WIDE]: "column" },
|
||||
borderRightWidth: 1,
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: colors.border,
|
||||
},
|
||||
brand: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "1rem",
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
sidebarNav: {
|
||||
flex: 1,
|
||||
paddingInline: "0.5rem",
|
||||
},
|
||||
column: {
|
||||
display: "flex",
|
||||
minHeight: "100dvh",
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
narrowOnly: {
|
||||
display: { default: null, [WIDE]: "none" },
|
||||
},
|
||||
narrowBrand: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
display: { default: null, [WIDE]: "none" },
|
||||
},
|
||||
headerRight: {
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
drawer: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
display: { default: null, [WIDE]: "none" },
|
||||
},
|
||||
drawerNav: {
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
padding: "1rem",
|
||||
},
|
||||
});
|
||||
|
||||
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
return (
|
||||
<ul {...stylex.props(styles.navList)}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.to}>
|
||||
<Link
|
||||
to={item.to}
|
||||
onClick={onNavigate}
|
||||
activeOptions={{ exact: item.to === "/" }}
|
||||
activeProps={{
|
||||
"aria-current": "page",
|
||||
className: stylex.props(styles.navActive).className,
|
||||
}}
|
||||
inactiveProps={{ className: stylex.props(styles.navIdle).className }}
|
||||
{...stylex.props(styles.navLink, shared.focusRing)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
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)})`}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutButton() {
|
||||
const { authRequired, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
if (authRequired !== true) return null;
|
||||
return (
|
||||
<div {...stylex.props(styles.logoutRow)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
void logout().then(
|
||||
() => navigate({ to: "/login" }),
|
||||
(logoutError: unknown) => setError(logoutError),
|
||||
);
|
||||
}}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
{error !== null && <InlineError error={error} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppShell() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
return (
|
||||
<div {...stylex.props(styles.shell)}>
|
||||
<aside {...stylex.props(styles.sidebar)}>
|
||||
<div {...stylex.props(styles.brand)}>nxdns</div>
|
||||
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
|
||||
<NavLinks />
|
||||
</nav>
|
||||
<VersionFooter />
|
||||
</aside>
|
||||
<div {...stylex.props(styles.column)}>
|
||||
<header {...stylex.props(styles.header)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls="mobile-nav"
|
||||
onClick={() => setDrawerOpen((open) => !open)}
|
||||
{...stylex.props(shared.button, styles.narrowOnly, shared.focusRing)}
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
|
||||
<div {...stylex.props(styles.headerRight)}>
|
||||
<PauseWidget />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
<RestartBanner />
|
||||
<ReadOnlyConfigBanner />
|
||||
{drawerOpen && (
|
||||
<div id="mobile-nav" {...stylex.props(styles.drawer)}>
|
||||
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
|
||||
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
||||
</nav>
|
||||
<VersionFooter />
|
||||
</div>
|
||||
)}
|
||||
<main {...stylex.props(styles.main)}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* The CSS entry (milestone 23, ruling 1). The StyleX plugin emits the compiled
|
||||
* atomic rules into their own layers; what is left here is the element reset
|
||||
* those rules are written against.
|
||||
*
|
||||
* The reset is not optional and not cosmetic. Tailwind's preflight used to
|
||||
* supply it, so every StyleX style in `admin/src` is written assuming border-box
|
||||
* sizing, no default margins, unstyled lists and form controls that inherit
|
||||
* their font. Deleting this block does not restore browser defaults — it
|
||||
* silently changes the meaning of every size and spacing value in the app.
|
||||
* Rules are limited to what this app renders; it is not a general reset.
|
||||
*
|
||||
* The reset lives in its own cascade layer, declared here before StyleX emits
|
||||
* its own. Layer order is priority order, and unlayered author CSS outranks
|
||||
* every layer: leaving these rules unlayered silently beat every StyleX rule in
|
||||
* the app, whatever the selector said.
|
||||
*/
|
||||
|
||||
@layer reset {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* This is not a reproduction of Tailwind's default stack, which is gone with
|
||||
* the package; text metrics drift here, which ruling 7 accepts. The symbol
|
||||
* families at the end are not decoration: `Select.tsx` renders U+25BE for its
|
||||
* chevron, and Segoe UI does not carry that glyph, so on Windows the stack
|
||||
* has to reach a font that does before it falls back to a substitute box.
|
||||
*/
|
||||
html {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif,
|
||||
"Segoe UI Symbol",
|
||||
"Noto Sans Symbols 2";
|
||||
}
|
||||
|
||||
/* Headings carry their scale from StyleX, not from the user agent. */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol,
|
||||
menu {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Links opt into colour and underline; the shell's nav wants neither. */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Without this the spinner stretches a number input taller than its row. */
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Firefox draws a red glow on an invalid field; the form shows its own error. */
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* Form controls: inherit type and colour, drop the user-agent chrome, and keep
|
||||
* `appearance: button` so iOS Safari honours a button's border radius.
|
||||
*/
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border: 0 solid;
|
||||
border-radius: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="reset"],
|
||||
input[type="submit"] {
|
||||
appearance: button;
|
||||
}
|
||||
|
||||
/* A checkbox keeps its native chrome; the rules above would erase it. */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* WebKit lays a date/time input out from its own pseudo-elements, and an
|
||||
* empty one comes out shorter than a filled one without these. The query log
|
||||
* filters are two `datetime-local` inputs sitting in a row of controls, so
|
||||
* the height has to hold whether or not a value is set.
|
||||
*/
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit,
|
||||
::-webkit-datetime-edit-year-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-minute-field,
|
||||
::-webkit-datetime-edit-second-field,
|
||||
::-webkit-datetime-edit-millisecond-field,
|
||||
::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Safari below 16.4 resolves `color-mix` against the wrong colour here and
|
||||
* renders the placeholder invisible. The guard admits every engine that
|
||||
* supports either a non-WebKit feature or one Safari only gained afterwards.
|
||||
*/
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* An inline SVG leaves a baseline gap under a full-width chart. */
|
||||
svg,
|
||||
img,
|
||||
video,
|
||||
canvas {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[hidden]:not([hidden="until-found"]) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The destructive-action confirmation (milestone 23, ruling 5), which replaces
|
||||
* every `window.confirm` call in the app.
|
||||
*
|
||||
* `role="alertdialog"` rather than `dialog`: the message is the reason the
|
||||
* dialog exists, so it is announced with the dialog instead of after it. The
|
||||
* overlay is deliberately not dismissable — a delete needs an explicit answer,
|
||||
* and a stray outside click is not one. Escape still cancels.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
import { styles as shared } from "./styles";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
/** The full sentence the operator reads before confirming; names the entity. */
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
overlay: {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
panel: {
|
||||
width: "100%",
|
||||
maxWidth: "26rem",
|
||||
borderRadius: "0.5rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.dangerBorder,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
padding: "1.5rem",
|
||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
body: {
|
||||
outlineStyle: "none",
|
||||
},
|
||||
title: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
message: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1.5rem",
|
||||
},
|
||||
dangerButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.danger,
|
||||
color: colors.primaryText,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
});
|
||||
|
||||
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onCancel();
|
||||
}}
|
||||
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||
>
|
||||
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
||||
<AriaDialog role="alertdialog" {...stylex.props(styles.body)}>
|
||||
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
|
||||
{title}
|
||||
</Heading>
|
||||
<p {...stylex.props(styles.message)}>{message}</p>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
{...stylex.props(styles.dangerButton, shared.focusRing)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The modal dialog (milestone 23, ruling 4).
|
||||
*
|
||||
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
|
||||
* wiring that the hand-rolled overlay only approximated. State is controlled by
|
||||
* the caller because the trigger is a table row button, not a `DialogTrigger`.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
interface Props {
|
||||
/** The dialog's accessible name. */
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
overlay: {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
panel: {
|
||||
width: "100%",
|
||||
maxWidth: "28rem",
|
||||
borderRadius: "0.5rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
padding: "1.5rem",
|
||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
/** The panel already draws the boundary; the dialog's own ring would double it. */
|
||||
body: {
|
||||
outlineStyle: "none",
|
||||
},
|
||||
});
|
||||
|
||||
export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
isDismissable
|
||||
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||
>
|
||||
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
||||
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
|
||||
{children}
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* The single-choice picker (milestone 23, ruling 4), replacing every native
|
||||
* `<select>` in the app.
|
||||
*
|
||||
* RAC composes a Select out of Label, Button, SelectValue, Popover, ListBox and
|
||||
* ListBoxItem; those parts are the Select, not extra components adopted beyond
|
||||
* ruling 4's scope, and nothing outside this file imports them.
|
||||
*
|
||||
* Keys are strings because a `<select>`'s value was a string. A call site that
|
||||
* models an id converts on both edges.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Label, ListBox, ListBoxItem, Popover, Select as AriaSelect, SelectValue } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
import { styles as shared } from "./styles";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: readonly SelectOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The visible label. Omit it only when `aria-label` names the control. */
|
||||
label?: string;
|
||||
"aria-label"?: 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.
|
||||
*/
|
||||
variant?: "field" | "compactField" | "inline";
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
root: {
|
||||
display: "block",
|
||||
},
|
||||
label: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
trigger: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
},
|
||||
compact: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
|
||||
value: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
chevron: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
popover: {
|
||||
width: "var(--trigger-width)",
|
||||
maxHeight: "16rem",
|
||||
overflowY: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
listBox: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
item: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
cursor: "pointer",
|
||||
},
|
||||
/**
|
||||
* The inverted row is the listbox convention, and the ring is the milestone-9
|
||||
* floor; an option gets both. RAC focuses the option's own DOM node, so the
|
||||
* shared `:focus-visible` ring does apply here — it is drawn inset because an
|
||||
* option flush against a scrolling popover clips an outset one, and recoloured
|
||||
* because the focus token is the same blue this row just painted behind it.
|
||||
*/
|
||||
itemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
itemSelected: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
|
||||
const base = variant === "field" ? shared.input : shared.smallInput;
|
||||
const block = variant === "compactField" ? styles.compact : null;
|
||||
return (
|
||||
<AriaSelect
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={(key) => onChange(String(key ?? ""))}
|
||||
{...stylex.props(styles.root)}
|
||||
>
|
||||
{label !== undefined && <Label {...stylex.props(styles.label)}>{label}</Label>}
|
||||
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
|
||||
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
|
||||
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
|
||||
▾
|
||||
</span>
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
<ListBox {...stylex.props(styles.listBox)}>
|
||||
{options.map((option) => (
|
||||
<ListBoxItem
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className={({ isFocused, isSelected }) =>
|
||||
stylex.props(
|
||||
styles.item,
|
||||
shared.insetFocusRing,
|
||||
isSelected && styles.itemSelected,
|
||||
isFocused && styles.itemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* The tab switcher (milestone 23, ruling 4).
|
||||
*
|
||||
* The hand-rolled version spelled the ARIA attributes by hand but had no
|
||||
* keyboard navigation; React Aria brings arrow-key movement and roving
|
||||
* tabindex with the same roles. State stays inside RAC — no caller needs to
|
||||
* read which tab is open.
|
||||
*
|
||||
* RAC exposes state as render-prop booleans, so every rule below is a
|
||||
* boolean-guarded style object: StyleX cannot express `[data-selected]`.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
export interface TabSpec {
|
||||
id: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The tab list's accessible name. */
|
||||
label: string;
|
||||
tabs: readonly TabSpec[];
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
list: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tab: {
|
||||
marginBottom: -1,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: "transparent",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
color: colors.textMuted,
|
||||
cursor: "pointer",
|
||||
},
|
||||
tabSelected: {
|
||||
borderBottomColor: colors.primary,
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
tabHovered: {
|
||||
color: colors.text,
|
||||
},
|
||||
/**
|
||||
* The milestone-9 focus floor. A Tab is a `div` with a roving tabindex, so
|
||||
* the ring is driven by RAC's `isFocusVisible` rather than `:focus-visible`.
|
||||
*/
|
||||
tabFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
panel: {
|
||||
outlineStyle: "none",
|
||||
},
|
||||
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
|
||||
root: {
|
||||
display: "block",
|
||||
},
|
||||
});
|
||||
|
||||
export default function Tabs({ label, tabs }: Props) {
|
||||
return (
|
||||
<AriaTabs className={() => stylex.props(styles.root).className ?? ""}>
|
||||
<TabList aria-label={label} className={() => stylex.props(styles.list).className ?? ""}>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
className={({ isSelected, isHovered, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.tab,
|
||||
isHovered && !isSelected && styles.tabHovered,
|
||||
isSelected && styles.tabSelected,
|
||||
isFocusVisible && styles.tabFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} id={tab.id} className={() => stylex.props(styles.panel).className ?? ""}>
|
||||
{tab.content}
|
||||
</TabPanel>
|
||||
))}
|
||||
</AriaTabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { styles } from "./styles";
|
||||
|
||||
/**
|
||||
* The StyleX compile-time transform must run in the vitest pipeline, not just
|
||||
* in `vite build`. Without the plugin these tests do not fail an assertion —
|
||||
* importing the module throws `Unexpected 'stylex.defineVars' call at runtime`
|
||||
* before a single case runs, which is the louder failure of the two
|
||||
* (milestone 23, ruling 1).
|
||||
*/
|
||||
describe("the StyleX build integration", () => {
|
||||
it("compiles stylex.props into a class name", () => {
|
||||
render(
|
||||
<button type="button" {...stylex.props(styles.button, styles.focusRing)}>
|
||||
probe
|
||||
</button>,
|
||||
);
|
||||
const probe = screen.getByRole("button", { name: "probe" });
|
||||
expect(probe.className).not.toBe("");
|
||||
});
|
||||
|
||||
it("keeps composed styles distinct from a single style", () => {
|
||||
const one = stylex.props(styles.button).className;
|
||||
const two = stylex.props(styles.button, styles.focusRing).className;
|
||||
expect(one).toBeTruthy();
|
||||
expect(two).not.toBe(one);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* The shared style vocabulary (milestone 23, ruling 3; replaces the Tailwind
|
||||
* class constants of milestone 18, ruling 10).
|
||||
*
|
||||
* Every interactive element must carry `focusRing` or `insetFocusRing`; that
|
||||
* is the milestone-9 accessibility floor. Compose at the call site with
|
||||
* `stylex.props(styles.button, extra)` instead of re-spelling a variant.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
const FOCUS = ":focus-visible";
|
||||
const DISABLED = ":disabled";
|
||||
|
||||
/** The half-fade loop a placeholder runs while its data is in flight. */
|
||||
const pulseFrames = stylex.keyframes({
|
||||
"50%": { opacity: 0.5 },
|
||||
});
|
||||
|
||||
export const styles = stylex.create({
|
||||
/** The ring drawn outside the element. */
|
||||
focusRing: {
|
||||
outlineWidth: { default: null, [FOCUS]: 2 },
|
||||
outlineStyle: { default: null, [FOCUS]: "solid" },
|
||||
outlineColor: { default: null, [FOCUS]: colors.focus },
|
||||
outlineOffset: { default: null, [FOCUS]: 2 },
|
||||
},
|
||||
/** The ring drawn inside the element, for controls flush against a panel edge. */
|
||||
insetFocusRing: {
|
||||
outlineWidth: { default: null, [FOCUS]: 2 },
|
||||
outlineStyle: { default: null, [FOCUS]: "solid" },
|
||||
outlineColor: { default: null, [FOCUS]: colors.focus },
|
||||
outlineOffset: { default: null, [FOCUS]: -2 },
|
||||
},
|
||||
|
||||
input: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
smallInput: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
|
||||
button: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
smallButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
largeButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
|
||||
primaryButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
largePrimaryButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
|
||||
rowButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
linkButton: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
dangerLinkButton: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
color: colors.danger,
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.dangerBorder,
|
||||
backgroundColor: "transparent",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
color: colors.dangerText,
|
||||
},
|
||||
|
||||
/** Marks an element as waiting on data. Carries no colour of its own. */
|
||||
pulse: {
|
||||
animationName: pulseFrames,
|
||||
animationDuration: "2s",
|
||||
animationTimingFunction: "cubic-bezier(0.4, 0, 0.6, 1)",
|
||||
animationIterationCount: "infinite",
|
||||
},
|
||||
/** Digits of equal width, so a column of counts does not jitter as it updates. */
|
||||
tabularNums: {
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
},
|
||||
/** For a value the operator reads character by character: a domain, an IP, a URL. */
|
||||
mono: {
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
},
|
||||
/** Visible to a screen reader only; the element keeps its place in the a11y tree. */
|
||||
srOnly: {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clipPath: "inset(50%)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
},
|
||||
|
||||
th: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.borderStrong,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
textAlign: "left",
|
||||
fontWeight: 500,
|
||||
},
|
||||
td: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
},
|
||||
/**
|
||||
* Not a faithful port: `space-y-3` set sibling margins on a block container,
|
||||
* this is a flex formatting context. StyleX has no way to write the `> * + *`
|
||||
* selector that `space-y` compiles to, so the rhythm can only come from
|
||||
* `gap`. Child sizing and margin behaviour differ from the Tailwind original;
|
||||
* both call sites are plain vertical form stacks, where they do not.
|
||||
*/
|
||||
formCard: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1rem",
|
||||
maxWidth: "32rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
padding: "1rem",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The design tokens (milestone 23, ruling 2).
|
||||
*
|
||||
* Values are the Tailwind 4 zinc/blue/red ramps the app rendered before the
|
||||
* StyleX conversion, carried over verbatim so the palette does not shift.
|
||||
* Each token holds its dark value under `prefers-color-scheme: dark` — the
|
||||
* same media strategy Tailwind 4 defaulted to. There is no theme toggle and
|
||||
* no `data-theme` attribute; the scheme follows the operating system.
|
||||
*
|
||||
* Name tokens by role, never by shade: a call site asks for `border`, not for
|
||||
* zinc-200.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
export const colors = stylex.defineVars({
|
||||
/** The page ground. */
|
||||
surface: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(14.1% 0.005 285.823)" },
|
||||
/** Cards, dialogs and inputs sitting on top of the ground. */
|
||||
surfaceRaised: { default: "#fff", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
/** The fill under a pointer on a nav item or a menu row. */
|
||||
surfaceHover: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||
/** Hairlines between rows and around cards. */
|
||||
border: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||
/** Control outlines, which need more contrast than a row divider. */
|
||||
borderStrong: { default: "oklch(87.1% 0.006 286.286)", [DARK]: "oklch(37% 0.013 285.805)" },
|
||||
text: { default: "oklch(21% 0.006 285.885)", [DARK]: "oklch(96.7% 0.001 286.375)" },
|
||||
/**
|
||||
* Secondary text. Most call sites were a flat zinc-500 before the conversion,
|
||||
* with no dark override, which measured 4.12:1 on the dark ground and so
|
||||
* failed WCAG AA; the dark value here is zinc-400, which measures 7.56:1.
|
||||
*/
|
||||
textMuted: { default: "oklch(55.2% 0.016 285.938)", [DARK]: "oklch(70.5% 0.015 286.067)" },
|
||||
/**
|
||||
* Secondary text that already adapted before the conversion: zinc-600 on the
|
||||
* light ground, zinc-400 on the dark one. It is a separate token because
|
||||
* `textMuted`'s lighter light value measures 4.62:1 against 7.40:1 here, and
|
||||
* these call sites are small text — table headers, an inactive nav item, a
|
||||
* 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)" },
|
||||
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)" },
|
||||
/**
|
||||
* Warnings: a degraded condition the operator can still act on, as against
|
||||
* `danger`, which is a failure or a destructive action. The amber ramp.
|
||||
*/
|
||||
warnSurface: { default: "oklch(98.7% 0.022 95.277)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||
warnBorder: { default: "oklch(87.9% 0.169 91.605)", [DARK]: "oklch(47.3% 0.137 46.201)" },
|
||||
/** A control's outline inside a warning banner, which the banner border would swallow. */
|
||||
warnBorderStrong: { default: "oklch(82.8% 0.189 84.429)", [DARK]: "oklch(55.5% 0.163 48.998)" },
|
||||
warnText: { default: "oklch(41.4% 0.112 45.904)", [DARK]: "oklch(96.2% 0.059 95.617)" },
|
||||
danger: { default: "oklch(57.7% 0.245 27.325)", [DARK]: "oklch(70.4% 0.191 22.216)" },
|
||||
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 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)" },
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user