From 617cc966a262a6496576927cff99936c33aeefc5 Mon Sep 17 00:00:00 2001
From: m5r
Date: Sun, 2 Aug 2026 13:04:09 +0200
Subject: [PATCH] milestone 9: react spa admin ui, frontend ci and embedded
dist
---
.gitea/workflows/ci.yml | 84 +-
.gitignore | 2 +
specs/milestone-9.md | 409 +++
src/web/static.zig | 33 +-
web/.prettierignore | 3 +
web/index.html | 13 +
web/package-lock.json | 3083 +++++++++++++++++
web/package.json | 48 +
web/public/favicon.svg | 4 +
web/src/auth/LoginPage.test.tsx | 97 +
web/src/auth/LoginPage.tsx | 117 +
web/src/auth/store.test.tsx | 89 +
web/src/auth/store.tsx | 105 +
web/src/features/blocklists/BlocklistForm.tsx | 89 +
.../blocklists/BlocklistsPage.test.tsx | 177 +
.../features/blocklists/BlocklistsPage.tsx | 170 +
.../blocklists/SourceStatusSection.tsx | 81 +
web/src/features/clients/ClientEditDialog.tsx | 86 +
web/src/features/clients/ClientsPage.test.tsx | 125 +
web/src/features/clients/ClientsPage.tsx | 115 +
web/src/features/clients/PrefixesEditor.tsx | 128 +
web/src/features/clients/prefixEditor.test.ts | 86 +
web/src/features/clients/prefixEditor.ts | 74 +
.../features/dashboard/DashboardPage.test.tsx | 163 +
web/src/features/dashboard/DashboardPage.tsx | 99 +
web/src/features/dashboard/DiskCard.tsx | 35 +
web/src/features/dashboard/HealthBanners.tsx | 43 +
web/src/features/dashboard/StatCards.tsx | 44 +
.../features/dashboard/TimeseriesChart.tsx | 219 ++
.../dashboard/UpstreamHealthTable.tsx | 70 +
.../features/dashboard/chartLayout.test.ts | 93 +
web/src/features/dashboard/chartLayout.ts | 101 +
.../features/groups/GroupSourcesEditor.tsx | 79 +
web/src/features/groups/GroupsPage.test.tsx | 135 +
web/src/features/groups/GroupsPage.tsx | 188 +
web/src/features/groups/sourceSet.test.ts | 22 +
web/src/features/groups/sourceSet.ts | 11 +
web/src/features/live/LiveLogPage.test.tsx | 80 +
web/src/features/live/LiveLogPage.tsx | 114 +
web/src/features/live/fakeEventSource.ts | 26 +
web/src/features/live/ringBuffer.test.ts | 101 +
web/src/features/live/ringBuffer.ts | 53 +
web/src/features/live/useLiveQueries.test.tsx | 213 ++
web/src/features/live/useLiveQueries.ts | 171 +
web/src/features/local/LocalDnsPage.test.tsx | 85 +
web/src/features/local/LocalDnsPage.tsx | 78 +
web/src/features/local/RecordsTab.tsx | 247 ++
web/src/features/local/ZonesTab.tsx | 202 ++
web/src/features/lookup/LookupPage.test.tsx | 93 +
web/src/features/lookup/LookupPage.tsx | 211 ++
web/src/features/pause/PauseWidget.test.tsx | 194 ++
web/src/features/pause/PauseWidget.tsx | 124 +
.../features/queries/QueryLogPage.test.tsx | 257 ++
web/src/features/queries/QueryLogPage.tsx | 261 ++
web/src/features/queries/qtype.test.ts | 18 +
web/src/features/queries/qtype.ts | 27 +
web/src/features/rules/RulesPage.test.tsx | 108 +
web/src/features/rules/RulesPage.tsx | 171 +
.../features/settings/RestartBanner.test.tsx | 28 +
web/src/features/settings/RestartBanner.tsx | 21 +
.../features/settings/SettingsPage.test.tsx | 234 ++
web/src/features/settings/SettingsPage.tsx | 329 ++
web/src/features/settings/restartBanner.ts | 27 +
web/src/lib/InlineError.tsx | 39 +
web/src/lib/api.test.ts | 96 +
web/src/lib/api.ts | 235 ++
web/src/lib/format.test.ts | 23 +
web/src/lib/format.ts | 27 +
web/src/lib/queries.ts | 279 ++
web/src/lib/queryClient.ts | 42 +
web/src/lib/settingsDiff.test.ts | 104 +
web/src/lib/settingsDiff.ts | 40 +
web/src/lib/types.ts | 403 +++
web/src/main.tsx | 24 +
web/src/routes.tsx | 218 ++
web/src/shell/AppShell.test.tsx | 125 +
web/src/shell/AppShell.tsx | 127 +
web/src/styles.css | 1 +
web/tsconfig.app.json | 25 +
web/tsconfig.json | 4 +
web/tsconfig.node.json | 21 +
web/vite.config.ts | 24 +
82 files changed, 11833 insertions(+), 17 deletions(-)
create mode 100644 specs/milestone-9.md
create mode 100644 web/.prettierignore
create mode 100644 web/index.html
create mode 100644 web/package-lock.json
create mode 100644 web/package.json
create mode 100644 web/public/favicon.svg
create mode 100644 web/src/auth/LoginPage.test.tsx
create mode 100644 web/src/auth/LoginPage.tsx
create mode 100644 web/src/auth/store.test.tsx
create mode 100644 web/src/auth/store.tsx
create mode 100644 web/src/features/blocklists/BlocklistForm.tsx
create mode 100644 web/src/features/blocklists/BlocklistsPage.test.tsx
create mode 100644 web/src/features/blocklists/BlocklistsPage.tsx
create mode 100644 web/src/features/blocklists/SourceStatusSection.tsx
create mode 100644 web/src/features/clients/ClientEditDialog.tsx
create mode 100644 web/src/features/clients/ClientsPage.test.tsx
create mode 100644 web/src/features/clients/ClientsPage.tsx
create mode 100644 web/src/features/clients/PrefixesEditor.tsx
create mode 100644 web/src/features/clients/prefixEditor.test.ts
create mode 100644 web/src/features/clients/prefixEditor.ts
create mode 100644 web/src/features/dashboard/DashboardPage.test.tsx
create mode 100644 web/src/features/dashboard/DashboardPage.tsx
create mode 100644 web/src/features/dashboard/DiskCard.tsx
create mode 100644 web/src/features/dashboard/HealthBanners.tsx
create mode 100644 web/src/features/dashboard/StatCards.tsx
create mode 100644 web/src/features/dashboard/TimeseriesChart.tsx
create mode 100644 web/src/features/dashboard/UpstreamHealthTable.tsx
create mode 100644 web/src/features/dashboard/chartLayout.test.ts
create mode 100644 web/src/features/dashboard/chartLayout.ts
create mode 100644 web/src/features/groups/GroupSourcesEditor.tsx
create mode 100644 web/src/features/groups/GroupsPage.test.tsx
create mode 100644 web/src/features/groups/GroupsPage.tsx
create mode 100644 web/src/features/groups/sourceSet.test.ts
create mode 100644 web/src/features/groups/sourceSet.ts
create mode 100644 web/src/features/live/LiveLogPage.test.tsx
create mode 100644 web/src/features/live/LiveLogPage.tsx
create mode 100644 web/src/features/live/fakeEventSource.ts
create mode 100644 web/src/features/live/ringBuffer.test.ts
create mode 100644 web/src/features/live/ringBuffer.ts
create mode 100644 web/src/features/live/useLiveQueries.test.tsx
create mode 100644 web/src/features/live/useLiveQueries.ts
create mode 100644 web/src/features/local/LocalDnsPage.test.tsx
create mode 100644 web/src/features/local/LocalDnsPage.tsx
create mode 100644 web/src/features/local/RecordsTab.tsx
create mode 100644 web/src/features/local/ZonesTab.tsx
create mode 100644 web/src/features/lookup/LookupPage.test.tsx
create mode 100644 web/src/features/lookup/LookupPage.tsx
create mode 100644 web/src/features/pause/PauseWidget.test.tsx
create mode 100644 web/src/features/pause/PauseWidget.tsx
create mode 100644 web/src/features/queries/QueryLogPage.test.tsx
create mode 100644 web/src/features/queries/QueryLogPage.tsx
create mode 100644 web/src/features/queries/qtype.test.ts
create mode 100644 web/src/features/queries/qtype.ts
create mode 100644 web/src/features/rules/RulesPage.test.tsx
create mode 100644 web/src/features/rules/RulesPage.tsx
create mode 100644 web/src/features/settings/RestartBanner.test.tsx
create mode 100644 web/src/features/settings/RestartBanner.tsx
create mode 100644 web/src/features/settings/SettingsPage.test.tsx
create mode 100644 web/src/features/settings/SettingsPage.tsx
create mode 100644 web/src/features/settings/restartBanner.ts
create mode 100644 web/src/lib/InlineError.tsx
create mode 100644 web/src/lib/api.test.ts
create mode 100644 web/src/lib/api.ts
create mode 100644 web/src/lib/format.test.ts
create mode 100644 web/src/lib/format.ts
create mode 100644 web/src/lib/queries.ts
create mode 100644 web/src/lib/queryClient.ts
create mode 100644 web/src/lib/settingsDiff.test.ts
create mode 100644 web/src/lib/settingsDiff.ts
create mode 100644 web/src/lib/types.ts
create mode 100644 web/src/main.tsx
create mode 100644 web/src/routes.tsx
create mode 100644 web/src/shell/AppShell.test.tsx
create mode 100644 web/src/shell/AppShell.tsx
create mode 100644 web/src/styles.css
create mode 100644 web/tsconfig.app.json
create mode 100644 web/tsconfig.json
create mode 100644 web/tsconfig.node.json
create mode 100644 web/vite.config.ts
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 0bc66e4..1915cc8 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -8,6 +8,7 @@ on:
env:
ZIG_VERSION: "0.16.0"
+ NODE_VERSION: "24"
jobs:
test:
@@ -24,6 +25,43 @@ jobs:
- name: Run test suite (unit + hermetic loopback integration)
run: zig build test -Dintegration
+ frontend:
+ runs-on: ubuntu-24.04
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+
+ - name: Install dependencies
+ working-directory: web
+ run: npm ci
+
+ - name: Check formatting
+ working-directory: web
+ run: npm run format:check
+
+ - name: Lint
+ working-directory: web
+ run: npm run lint
+
+ - name: Typecheck
+ working-directory: web
+ run: npm run typecheck
+
+ - name: Run tests
+ working-directory: web
+ run: npm test
+
+ - name: Build
+ working-directory: web
+ run: npm run build
+
cross:
runs-on: ubuntu-24.04
@@ -35,19 +73,42 @@ jobs:
with:
version: ${{ env.ZIG_VERSION }}
- - name: Build static musl executables
- run: zig build cross
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: npm
+ cache-dependency-path: web/package-lock.json
- - name: Install file(1)
+ - name: Build the web UI
+ working-directory: web
run: |
- if ! command -v file >/dev/null 2>&1; then
+ npm ci
+ npm run build
+
+ # ReleaseSafe because the < 15 MB budget (PLAN §18) is for release
+ # binaries; a Debug build strips to ~25 MB and can never meet it.
+ - name: Build static musl executables
+ run: zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
+
+ - name: Install file(1) and strip tooling
+ run: |
+ missing=""
+ command -v file >/dev/null 2>&1 || missing="$missing file"
+ command -v objcopy >/dev/null 2>&1 || missing="$missing binutils"
+ command -v aarch64-linux-gnu-objcopy >/dev/null 2>&1 || missing="$missing binutils-aarch64-linux-gnu"
+ if [ -n "$missing" ]; then
sudo apt-get update -qq
- sudo apt-get install -qq -y file
+ sudo apt-get install -qq -y $missing
fi
- - name: Assert executables exist and are statically linked
+ # The size budget applies to stripped binaries (PLAN §18) and
+ # `zig build cross` does not strip, so the assert measures a
+ # stripped copy and leaves the built artifact untouched.
+ - name: Assert executables are statically linked and within the size budget
run: |
set -euo pipefail
+ size_limit=$((15 * 1024 * 1024))
for triple in x86_64-linux-musl aarch64-linux-musl; do
binary="zig-out/cross/$triple/nxdns"
if [ ! -f "$binary" ]; then
@@ -63,4 +124,15 @@ jobs:
exit 1
;;
esac
+ case "$triple" in
+ x86_64-*) strip_tool=objcopy ;;
+ aarch64-*) strip_tool=aarch64-linux-gnu-objcopy ;;
+ esac
+ "$strip_tool" --strip-all "$binary" "$binary.stripped"
+ size=$(stat -c %s "$binary.stripped")
+ echo "$triple: stripped size $size bytes"
+ if [ "$size" -ge "$size_limit" ]; then
+ echo "stripped executable exceeds the 15 MB budget: $binary"
+ exit 1
+ fi
done
diff --git a/.gitignore b/.gitignore
index 03cb27d..4ddfa3b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
.zig-cache/
zig-out/
zig-pkg/
+web/node_modules/
+web/dist/
diff --git a/specs/milestone-9.md b/specs/milestone-9.md
new file mode 100644
index 0000000..ba61a2c
--- /dev/null
+++ b/specs/milestone-9.md
@@ -0,0 +1,409 @@
+# Milestone 9: React SPA admin UI (PLAN §14, §3.14)
+
+Goal: the complete admin UI — ten pages per PLAN.md:560, login, pause control, restart
+banner — built with Vite + React + TypeScript + Tailwind + TanStack Router/Query,
+embedded in the binary via the existing `-Dweb-dist` pipeline, with a frontend CI job.
+
+Ground truth: PLAN.md:118-120 (auth UX), :138-140 (stack + embedding), :145/:579 (CI),
+:454-456 (live view), :531 (restart banner), :560-562 (pages + requirements), :639 (size
+budget), :641 (upstream health in UI); specs/milestone-8.md W8/W9 As-built (asset
+pipeline, SPA fallback, --web-dev); src/web/openapi.yaml (the API contract — authorative
+for every body shape; do NOT guess fields, read it).
+
+## Rulings (binding)
+
+1. **Stack per PLAN.md:138, not house style**: Vite + React 19 + TypeScript + Tailwind
+ v4 (CSS-first config, no tailwind.config.js) + TanStack Router + TanStack Query. The
+ reference repos use StyleX and no TanStack; PLAN is the source of truth here. SPA, no
+ SSR, base `/`.
+2. **Location**: everything under `web/` (PLAN.md:234): `web/package.json`, `web/src/`,
+ `web/index.html`, build output `web/dist/` (gitignored). `web/dist-placeholder/`
+ stays untouched and stays the `-Dweb-dist` default.
+3. **Toolchain**: node >= 24 (`engines`), npm with committed `web/package-lock.json`.
+ Dependencies exact-pinned (house style). Checks: `tsc --noEmit`, Prettier (house
+ config: tabs, tabWidth 4, printWidth 120, semi, double quotes, trailing commas all),
+ oxlint (zero-config), vitest + @testing-library/react + jsdom for tests. No ESLint,
+ no Biome, no msw — tests stub `fetch`/`EventSource` by hand.
+4. **No charting dependency.** The dashboard chart is a hand-rolled SVG component
+ (stacked bars for queries/blocked/cached over the fixed zero-filled buckets, axis
+ labels, hover tooltip via native `` + a hover state). The data is small
+ (60-168 buckets) and fixed-shape; a chart library is a liability with no payoff.
+ This is complete, not a stub: axes, tooltip, empty state, responsive width.
+5. **Router**: code-based route tree (no file-based codegen plugin). Every data route
+ uses a TanStack Router loader that primes TanStack Query (`ensureQueryData`) —
+ PLAN.md:562 "route loaders for initial fetch". Pending/error components on every
+ route; TanStack Query handles cache/retry (no retry on 4xx; retry 429 after
+ Retry-After).
+6. **API layer**: one `src/lib/api.ts` typed client over `fetch` with
+ `credentials: "same-origin"`. Types in `src/lib/types.ts` transcribed by hand from
+ openapi.yaml (snake_case preserved; no codegen dependency). Error model:
+ `ApiError{status, message, retryAfter?}` from the `{error}` envelope; 503 rendered
+ distinctly from 500 ("server starting/degraded" vs "internal error").
+7. **Auth flow**: `/login` route outside the app shell. On any 401 the query layer
+ redirects to `/login?redirect=`. Login POST with `auth_required=false` in the
+ response → auth is off → navigate straight in (the login page short-circuits by
+ probing once). Logout button in the shell (hidden when auth is off). Session state
+ lives in a tiny auth store fed by responses, not a poll. Works with auth on AND off
+ (PLAN.md:562) — with auth off the SPA never shows login.
+8. **Pause control is global**: a shell-header widget (pause/resume with duration
+ presets 60s/5m/30m/indefinite, countdown from `until`, `paused` disambiguates null
+ `until`). Not a page.
+9. **Live log page**: `EventSource` on `/api/queries/live`; ring buffer of 500 rows in
+ memory, newest first; rows keyed by a monotonically increasing client counter (the
+ SSE payload has NO id). On `error`/close the browser auto-reconnects (server sends
+ `retry: 3000`); on reconnect the page re-syncs the gap via `GET /api/queries`
+ (since = last seen ts) and shows a "stream resumed, N missed" notice. Pause-stream
+ button (client-side freeze) included. 429 from the SSE cap → visible "too many live
+ viewers" state, retry button.
+10. **Query log page**: keyset pagination exactly per contract (`limit`, `before`,
+ follow `next_before` until null; newest first). Filters: domain substring, client
+ exact, blocked tri-state, since/until (datetime-local inputs → unix seconds).
+ "Load more" appends; filter change resets the cursor.
+11. **Settings page**: sections rendered from the GET envelope; a diff-based PUT sends
+ ONLY changed fields (partial patch per contract). Every key is restart-required
+ today: after a successful PUT of anything except `web.password`, set a persistent
+ "restart to apply" banner (dismiss resets on next change; PLAN.md:531).
+ `web.password` is a write-only field with confirm input; on change the API kills
+ all sessions — the SPA expects the next request to 401 and routes to login.
+ `web.auth_enabled` is read-only derived. `web.password_hash` is never sent.
+12. **Blocklists page** shows per-source `skipped_regex_count` (PLAN.md:38) and source
+ status (state, last_success, counts, last_error). "Update now" calls
+ `POST /api/blocklists/update`, REPLACES list state from the 202 snapshot, disables
+ the button while in flight.
+13. **Dashboard**: stats totals + timeseries chart (period picker 1h/24h/7d/30d),
+ upstream health table (PLAN.md:641), disk card from `/api/health`
+ (state/free/db/log bytes) with the warning banner when `disk.state != "ok"`
+ (PLAN.md:465), plus queries_dropped/writer_failed indicators. Auto-refresh via
+ Query `refetchInterval` 30s (health 10s).
+14. **Groups page** includes the group↔sources assignment editor
+ (`PUT /api/groups/{id}/sources`, full-set checkboxes) and safe_search toggle.
+ Default group (id 1) shows but blocks rename/delete client-side too (server 409s).
+15. **Clients page**: table of all clients (`hand_edited` badge), edit name/group, no
+ create (rows appear from DNS activity — say so in the empty state), delete with
+ "re-materializes on next query" note. Client-prefixes editor on the same page:
+ whole-list editing per the PUT contract.
+16. **Local DNS page**: two tabs (records, forward zones), CRUD forms per contract.
+ **Domain lookup page**: domain + group select → renders the full pipeline verdict
+ (local_records, forward_zone, blocked/reason/matched/source_url,
+ safe_search_rewrite).
+17. **Errors and loading**: every route has skeleton/pending UI and an error boundary
+ with the ApiError message + retry (PLAN.md:562). Mutations surface 400/409 messages
+ inline at the form, 429 with countdown, 401 via the global redirect.
+18. **Responsive**: sidebar nav collapses to a top bar + drawer under `md:`; tables get
+ `overflow-x-auto` wrappers; the dashboard grid stacks. Desktop and mobile per
+ PLAN.md:562 — no separate mobile pages.
+19. **Formatting/l10n**: timestamps rendered in the browser locale from unix seconds
+ (`Intl.DateTimeFormat`), byte counts humanized (KiB/MiB/GiB), µs durations shown as
+ ms with one decimal. One `src/lib/format.ts`, tested.
+20. **Size budget**: PLAN.md:639 — stripped static binary < 15 MB with assets (< 10 MB
+ without → SPA budget ≈ 5 MB embedded, plain + .gz both count). React+TanStack+
+ Tailwind lands far under that; CI asserts the final cross binaries < 15 MB.
+21. **Zig side is frozen.** No changes to src/, build.zig, tools/, or openapi.yaml. If
+ the SPA reveals an API bug, STOP and report — do not work around silently. The only
+ Zig-adjacent deliverables are ci.yml additions and `.gitignore` entries.
+22. **CI**: new `frontend` job (setup-node@v4, `NODE_VERSION: "24"` env pin,
+ `cache: npm`, `cache-dependency-path: web/package-lock.json`; `npm ci` →
+ `prettier --check` → oxlint → `tsc --noEmit` → `vitest run` → `vite build`),
+ uploading `web/dist` as an artifact is NOT needed — instead the existing `cross`
+ job gains: setup-node, `npm ci && npm run build` (prefix web), then
+ `zig build cross -Dweb-dist=web/dist`, then the existing static assert plus a
+ size assert (< 15 MB per exe). The `test` job stays Zig-only.
+23. **Accessibility floor**: semantic elements, labeled inputs, focus-visible styles,
+ buttons not divs. No ARIA deep-dive beyond what semantics give.
+24. **No new pages, no dark-mode toggle bikeshed** (Tailwind default palette, system
+ `prefers-color-scheme` via CSS only), no i18n framework, no state library beyond
+ TanStack Query + two tiny stores (auth, restart-banner) in React context.
+
+## Sessions
+
+F1 first (scaffold), then F2 (api/lib) sequential on F1, then F3-F8 parallel (pages;
+disjoint files), then F9 (CI + embed + smoke) after all.
+
+---
+
+## Session F1: scaffold + shell
+
+Owns: `web/package.json`, `web/package-lock.json`, `web/index.html`,
+`web/vite.config.ts`, `web/tsconfig*.json`, `web/.oxlintrc.json` (only if needed),
+`web/src/main.tsx`, `web/src/routes.tsx` (route tree with lazy page imports and
+placeholder page stubs F3-F8 replace), `web/src/shell/` (layout, sidebar/topbar nav,
+`PauseWidget` SLOT — an import of `../features/pause/PauseWidget` that F8 fills; F1
+ships the real widget file with a disabled placeholder), `web/src/styles.css`
+(tailwind), `.gitignore` additions (web/dist, web/node_modules), Prettier config in
+package.json (house values), scripts: dev/build/typecheck/lint/format/test.
+
+Vite: `@vitejs/plugin-react`, tailwind v4 via `@tailwindcss/vite`, build target
+baseline-widely-available (vite 8 default), no proxy needed for build; dev proxy
+`/api` + `/metrics` → `http://127.0.0.1:8080` for `vite dev` against a running nxdns.
+Route tree: `/login` bare; shell routes `/`, `/queries`, `/live`, `/clients`,
+`/groups`, `/blocklists`, `/rules`, `/local-dns`, `/lookup`, `/settings`.
+
+Acceptance: `npm ci && npm run build` produces `web/dist` with `/index.html`;
+typecheck/lint/format clean; placeholder pages render; nav works with keyboard.
+
+### F1 As built
+
+Pinned: react 19.2.8, @tanstack/react-router 1.170.18, @tanstack/react-query 5.101.4,
+vite 8.1.5, typescript 6.0.3, tailwindcss 4.3.3 (@tailwindcss/vite), vitest 4.1.10,
+@testing-library/react 16.3.2, jsdom 29.1.1, prettier 3.9.6, oxlint 1.75.0. All exact.
+~/.npmrc enforces min-release-age=7 (newest gate-clearing versions chosen) and
+ignore-scripts=true (works; native bins are optionalDependencies). TS6 deprecates
+baseUrl — tsconfig.app.json uses `paths` without it. Dist: 344K (main chunk 278.5 kB /
+88.2 kB gz, 11 lazy page chunks, CSS 8.8 kB). `zig build -Dweb-dist=web/dist` exit 0.
+- Alias `@/*` → `web/src/*` (tsconfig paths + vite resolve.alias).
+- Router: code-based in src/routes.tsx; `createAppRouter(history?)` exported (tests
+ pass createMemoryHistory); `Register` declared so `Link to` is typed; pages wired
+ via lazyRouteComponent at stable paths — every page keeps a default export at its
+ path; page sessions never edit routes.tsx. Shell = pathless layout route id "shell";
+ /login hangs off root outside it; defaultPreload "intent".
+- Shell: AppShell renders PauseWidget (default export, no props) from
+ features/pause/PauseWidget, header right; F8 replaces the file in place.
+ VersionFooter inside AppShell.tsx (F2 wires /api/version). Restart banner has no
+ premade slot — F8 mounts it in AppShell (sequential edit). Nav: aria-current +
+ activeProps; drawer under md: with aria-expanded/controls.
+- Vitest lives in vite.config.ts (jsdom, globals: true; tsconfig types include
+ vitest/globals). lint = `oxlint src vite.config.ts`; prettier ignores dist/,
+ dist-placeholder/, package-lock.json (.prettierignore).
+- Page filenames: QueryLogPage/LiveLogPage per F4 naming; LoginPage placeholder in
+ src/auth/ (F2 replaces).
+
+---
+
+## Session F2: API layer + auth + query plumbing
+
+Owns: `web/src/lib/` (`api.ts`, `types.ts`, `format.ts`, `queryClient.ts`,
+`queries.ts` — queryOptions per resource, mutation helpers with invalidation),
+`web/src/auth/` (store, `LoginPage`, 401 redirect wiring), tests for api/format/
+pagination/settings-diff helpers.
+
+types.ts transcribed from src/web/openapi.yaml — every shape the pages consume
+(QueryRow, QueriesPage, StatsTotals, Timeseries, Lookup, UpstreamHealth, Health,
+Version, Group, Blocklist, SourceStatus, Rule, LocalRecord, ForwardZone, Client,
+ClientPrefix, Upstream, SettingsEnvelope, Pause, Login). ApiError per ruling 6;
+401 hook per ruling 7; 429 retry per ruling 5; settings diff builder per ruling 11
+(pure function, tested).
+
+Acceptance: vitest green; typecheck clean; login/logout round trip works against a
+live `nxdns run` (manual smoke; document the transcript).
+
+### F2 As built
+
+lib/{types,api,queryClient,queries,format,settingsDiff}.ts + tests (22 green);
+auth/{store.tsx,LoginPage.tsx}; routes.tsx gained context+loaders+default
+pending/error components; main.tsx providers; AppShell gained logout button + live
+VersionFooter. Live-server smoke verified every consumed shape with auth on AND off;
+no API bugs. Main chunk 319 kB (100 kB gz).
+- api.ts: `request` core (same-origin, 204→void, `{error}` envelope, Retry-After on
+ 429), `requestText`, functions for all 55 route-method pairs, list envelopes
+ unwrapped to arrays, `liveQueriesUrl` for F4.
+- queryClient: staleTime 30s; no retry on 4xx except 429 (max 2, delay=retryAfter);
+ QueryCache+MutationCache onError → `/login?redirect=` on 401 (skipped
+ on /login).
+- queries.ts factories: healthQuery (10s refetch), versionQuery, statsQuery/
+ timeseriesQuery(period), upstreamHealthQuery (30s), queriesQuery(filter),
+ lookupQuery(domain, groupId?), groupsQuery, groupSourcesQuery(id), blocklistsQuery,
+ rulesQuery, localRecordsQuery, forwardZonesQuery, clientsQuery, clientPrefixesQuery,
+ upstreamsQuery, pauseQuery, settingsQuery. Mutations: `xxxMutation(queryClient)` →
+ useMutation options; invalidation map in the F2 report; NOTE
+ `blocklistsUpdateNowMutation` seeds `queryKeys.blocklistSources` from the 202
+ snapshot — the ONLY feed for source status (no GET exists); F6 reads that key.
+- Auth store: `useAuth()` → {authRequired: bool|null, probe(), login(password),
+ logout()}. Probe = POST login with empty password (no status GET), StrictMode-deduped;
+ authRequired mirrored in sessionStorage `nxdns_auth_required`.
+- Loaders prime: dashboard stats+timeseries("24h")+health+upstreamHealth; queries
+ `queriesQuery({})` (F4 "load more" calls api.getQueries imperatively and appends);
+ clients clients+prefixes+groups; groups groups+blocklists; rules rules+groups;
+ local-dns records+zones; lookup groups; settings settings; live none. Pages use the
+ same factory the loader primed (useSuspenseQuery/useQuery).
+- Router error component is ApiError-aware (503 "starting or degraded", 429 countdown,
+ ≥500 generic); Retry = router.invalidate().
+- Page-test pattern: wrap in AuthProvider+QueryClientProvider, pass the same qc to
+ `createAppRouter(history, qc)`, stub fetch per-URL (see AppShell.test.tsx).
+- api.ts sends `{}` on bodyless POSTs (logout, blocklists/update) — belt-and-braces
+ over the W9 server fix.
+
+---
+
+## Session F3: Dashboard + SVG chart
+
+Owns: `web/src/features/dashboard/` (page, StatCards, TimeseriesChart (SVG, ruling 4),
+UpstreamHealthTable, DiskCard, HealthBanners) + chart unit tests (bucket→bar math,
+empty state).
+
+## Session F4: Query log + Live log
+
+Owns: `web/src/features/queries/` (QueryLogPage, filters, cursor pagination per ruling
+10) and `web/src/features/live/` (LiveLogPage per ruling 9: EventSource wrapper hook
+with injected EventSource for tests, ring buffer, gap re-sync, freeze, cap-hit state).
+Tests: ring buffer, gap-resync math, EventSource hook with a fake.
+
+## Session F5: Clients + Groups
+
+Owns: `web/src/features/clients/` (table, edit dialog, prefixes editor per ruling 15)
+and `web/src/features/groups/` (list, create/rename/delete, safe_search, sources
+assignment per ruling 14).
+
+## Session F6: Blocklists + Rules
+
+Owns: `web/src/features/blocklists/` (ruling 12) and `web/src/features/rules/`
+(table with group/kind/action columns, create form with pattern kind select, delete).
+
+## Session F7: Local DNS + Lookup
+
+Owns: `web/src/features/local/` (records + zones tabs, CRUD forms) and
+`web/src/features/lookup/` (ruling 16).
+
+## Session F8: Settings + Pause widget
+
+Owns: `web/src/features/settings/` (ruling 11: section forms, diff PUT, restart
+banner store + banner component mounted in the shell via F1's slot, password flow)
+and `web/src/features/pause/PauseWidget.tsx` (REPLACES F1's placeholder; ruling 8).
+Tests: diff builder edge cases (nested partial, password only, no-op), banner logic.
+
+### F3-F8 As built (page wave)
+
+All six sessions green; 100 web tests total after the wave; main chunk 318 kB
+(99 kB gz), pages as lazy chunks.
+- **F3 Dashboard**: chartLayout.ts pure layout (layoutTimeseries, niceTicks 1/2/5) +
+ TimeseriesChart({data}) — self-measuring SVG stacked bars (blocked/cached/other,
+ other = queries−blocked−cached clamped ≥0), sr-only data table, role="img".
+ Axis ticks use a short Intl formatter (formatTime is tooltip/sr-only only).
+ UpstreamHealthTable shows total_failures; success_rate ×100 (0..1 verified in
+ pool.zig). HealthBanners: disk warn/critical (critical text per PLAN.md:466),
+ writer_failed, queries_dropped>0, all role="alert". 12 tests.
+- **F4 Query/Live**: QueryLogPage exports QueryCells/QueryTableHead/BlockedCell,
+ reused by LiveLogPage (both F4-owned). qtype.ts names 19 codes, TYPE fallback.
+ Filters in component state (not URL). ringBuffer.ts: 500 newest-first + mergeGap
+ (dedup key ts|domain|client_ip|qtype|blocked|upstream — SSE rows have no id;
+ same-second identical queries can over-dedup, accepted at household scale).
+ useLiveQueries hook (injected EventSourceLike factory + fetchSince): states
+ connecting/open/retrying/capped, gap re-sync getQueries({since, limit:500}) —
+ gaps >500 replace the buffer wholesale; CAP_ERROR_THRESHOLD=3 consecutive errors
+ → capped state (EventSource cannot see 429; message says cap OR unreachable),
+ open resets the counter. Freeze = display-only snapshot; the ring keeps filling;
+ Resume swaps to current. 25 tests.
+- **F5 Clients/Groups**: prefixEditor.ts pure reducer (priority "" omitted → server
+ default 100); baseline resets only on save/discard (dirty edits never clobbered by
+ refetch). clientUpdateMutation takes {id, edit} (spec prompt said input — code
+ wins). Groups: safe_search toggle PUTs with unchanged name (server 409s only on
+ actual rename of default — verified in groups.zig); default group protections
+ client-side + visible note; sourceSet.ts toggle/sameSet. 18 tests.
+- **F6 Blocklists/Rules**: rule enums confirmed kind=[exact,wildcard],
+ action=[allow,block]. Status section reads queryKeys.blocklistSources via
+ queryClient.getQueryData at render (no GET exists; mutation setQueryData
+ re-renders); edit PUT preserves is_suggested. 3 tests.
+- **F7 Local/Lookup**: rtype=[A,AAAA,CNAME], ttl optional default 300. SPEC
+ CORRECTION: Lookup.local_records is a BOOLEAN (ruling 16 said "list" loosely) —
+ rendered Yes/No; verdict priority local > blocked > forwarded > allowed (pipeline
+ order per lookup.zig), verdictOf exported. Local mutations live, no banner.
+ 4 tests.
+- **F8 Settings/Pause**: patchRequiresRestart(patch) exported — banner raised for
+ any patch except bare web.password; restartBanner.ts is a useSyncExternalStore
+ MODULE store (accepted deviation from ruling 24's "context": keeps the AppShell
+ edit to import + mount). Number inputs NaN-guard blocks Save. PauseWidget:
+ refetchInterval 5s only while paused, 1s countdown, formatRemaining exported.
+ 16 tests.
+- **Orchestrator integration**: the wave produced three near-identical inline-error
+ components (clients+groups InlineError, local FormError) — hoisted post-wave to
+ `src/lib/InlineError.tsx` (the richer variant: 400/409 verbatim, 429 countdown,
+ 503 distinct, non-Error → "could not reach"), seven imports updated, duplicates
+ deleted. Dashboard's InlineError (query-level, onRetry) is a different component
+ and stays local. Full chain re-verified green after the hoist.
+
+---
+
+## Session F9: CI + embed + smoke (after F3-F8)
+
+Owns: `.gitea/workflows/ci.yml` (ruling 22). Steps: verify `npm ci` reproducibility,
+frontend job, cross-job additions with `-Dweb-dist=web/dist` + size assert.
+Smoke (report transcript): `npm run build`; `zig build -Dweb-dist=web/dist`; boot;
+curl `/` returns the SPA index (not the placeholder); a deep link (`/settings`)
+returns 200 index; an asset serves gzip with ETag/304; login via browser-shaped curl
+flow still works; SIGTERM 0. Also `zig build test` and `-Dintegration` still green
+(the Zig tests embed the dist too — W10 static tests must still pass against the SPA
+dist; if one pins placeholder content, report it, do not edit Zig).
+
+### F9 As built
+
+ci.yml +77/-5: top-level `NODE_VERSION: "24"`; new `frontend` job (setup-node@v4,
+cache npm on web/package-lock.json; npm ci → format:check → lint → typecheck →
+`npm test` → build); `cross` job builds the SPA then
+`zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe` — RULED deviation:
+build.zig has no strip option and Debug exes are 87/32 MB (25.6 MB stripped), so the
+PLAN:639 budget is only meaningful for release builds; PLAN:50 calls these release
+binaries. Size assert strips COPIES via binutils (zig objcopy --strip-all is
+unimplemented in 0.16; aarch64 needs binutils-aarch64-linux-gnu, apt-installed
+conditionally). Measured ReleaseSafe: raw ~24.2 MB each; stripped x86_64 5,501,624
+bytes (PASS < 15 MB), aarch64 PT_LOAD total 4.87 MB (~5 MB stripped, asserted in CI).
+`test` job stays Zig-only on the placeholder dist.
+Smoke (embedded SPA): / serves the vite index (not the placeholder), /settings deep
+link 200, main chunk served gzip with ETag then 304 on If-None-Match, login round
+trip (401 → cookie → 200 → logout → 401), SIGTERM exit 0.
+Finding, fixed post-session under an orchestrator ruling (narrow Zig-freeze
+exception): the W8 test at static.zig:413 pinned placeholder strings and failed with
+any real dist — rewritten dist-agnostic (structural asserts: /index.html exists,
+text/html, gz siblings have base entries, quoted etags, no duplicate paths).
+Import CLI note: the config file is positional on `nxdns import` (no --config flag).
+
+---
+
+## Module layout (new)
+
+web/{package.json, package-lock.json, index.html, vite.config.ts, tsconfig*.json},
+web/src/{main.tsx, routes.tsx, styles.css}, web/src/shell/*, web/src/lib/*,
+web/src/auth/*, web/src/features/{dashboard,queries,live,clients,groups,blocklists,
+rules,local,lookup,settings,pause}/*.
+
+## File ownership
+
+F1 scaffold+shell+routes; F2 lib+auth; F3-F8 exactly their feature dirs (routes.tsx
+lazy imports point at stable paths F1 fixes up-front, so page sessions never edit
+routes.tsx; F8 alone replaces the PauseWidget file F1 created); F9 ci.yml. Orchestrator:
+spec, integration wiring if any. Parallel sessions never share a file.
+
+## Acceptance (milestone complete)
+
+- [ ] `npm ci`, format/lint/typecheck/vitest, `vite build` all green in web/.
+- [ ] All ten pages implemented per rulings; login + pause + restart banner work.
+- [ ] `zig build cross -Dweb-dist=web/dist` green; exes < 15 MB; W10 suite still green.
+- [ ] F9 smoke transcript: SPA served embedded, deep link 200, gzip+ETag, SIGTERM 0.
+- [ ] CI has the frontend job and the cross-job embed per ruling 22.
+- [ ] Spec As-built synced per session.
+
+## Review (Codex, As built)
+
+Round 1: 7 important + 4 minor, all fixed.
+- auth: AuthProvider probes once on mount when authRequired is null (dedupe kept;
+ sessionStorage fast path, probe overwrites); logout swallows ONLY 401 — other errors
+ rethrow and AppShell's LogoutButton renders them via InlineError, navigating only on
+ success; LoginPage 429 is a ticking lockout (submit + Enter disabled until zero).
+- queries: load-more generation counter — filter apply/clear increments; then/catch/
+ finally discard stale completions (no old-filter rows, no stale cursor); imperative
+ 401s route through `handleUnauthorized` (newly exported from lib/queryClient).
+- live: gap-resync 401 → handleUnauthorized (never resyncFailed); entering capped
+ fires a one-shot injectable probeSession (default GET /api/pause) so an expired
+ session redirects to login instead of reading as "capped".
+- settings: fieldset disabled while the PUT is pending (no silently dropped edits);
+ errors via InlineError.
+- pause: mutation errors rendered via InlineError in both branches (were silent);
+ stale error resets on pause-state flip.
+- blocklists/rules: all mutation errors via InlineError (429 countdown); form error
+ props are Error|null now.
+- Ripple: the auth mount probe legitimately fires POST /api/auth/login before page
+ fetches — LocalDnsPage.test's first-POST assertion narrowed to match by URL.
+After fixes: 24 files / 120 web tests green; Zig suite with the fresh dist 0 failed.
+
+Round 2: 1 important + 1 minor, both fixed. (a) keepPreviousData left the OLD
+filter's cursor clickable during the placeholder window — loadMore and the button now
+bail on base.isPlaceholderData (chosen over isFetching so background refetches of the
+current key stay usable); regression test proves no new-filter/old-cursor request.
+(b) safeRedirect accepted backslash network paths — now
+`/^\/(?![/\\])/.test(raw) && !raw.includes("\\")`, with unit cases.
+
+Round 3: no findings. Final: 121 web tests green.
+
+## Anti-requirements
+
+No SSR, no chart library, no codegen (openapi→TS), no msw, no ESLint, no i18n, no
+dark-mode toggle, no WebSocket, no service worker/PWA, no Zig changes, no docs/api
+rendering (Phase 10), no DoH/DoT UI (Phase 9 adds settings sections it already has).
diff --git a/src/web/static.zig b/src/web/static.zig
index b75a18c..4399fb8 100644
--- a/src/web/static.zig
+++ b/src/web/static.zig
@@ -410,21 +410,32 @@ test "dev-mode disk reads refuse a symlink that escapes the root" {
try testing.expect(!resolvesUnderRoot(root, io, "missing.txt"));
}
-test "the placeholder dist is embedded with its gzip siblings" {
+test "the embedded dist has an index and consistent gzip siblings" {
+ try testing.expect(embedded.len > 0);
+
const index = find(embedded, index_path).?;
try testing.expectEqualStrings("text/html; charset=utf-8", index.content_type);
- try testing.expect(std.mem.containsAtLeast(u8, index.bytes, 1, "nxdns"));
- try testing.expect(std.mem.containsAtLeast(u8, index.bytes, 1, "/api/health"));
+ try testing.expect(index.bytes.len > 0);
- const favicon = find(embedded, "/favicon.svg").?;
- try testing.expectEqualStrings("image/svg+xml", favicon.content_type);
+ for (embedded) |file| {
+ try testing.expect(file.etag.len >= 3);
+ try testing.expectEqual(@as(u8, '"'), file.etag[0]);
+ try testing.expectEqual(@as(u8, '"'), file.etag[file.etag.len - 1]);
- const gz = select(embedded, index_path, "gzip").?;
- try testing.expect(gz.gzip);
- try testing.expect(gz.file.bytes.len < index.bytes.len);
- // The gzip member header: build-time compression, not an accident.
- try testing.expectEqual(@as(u8, 0x1f), gz.file.bytes[0]);
- try testing.expectEqual(@as(u8, 0x8b), gz.file.bytes[1]);
+ var occurrences: usize = 0;
+ for (embedded) |other| {
+ if (std.mem.eql(u8, other.path, file.path)) occurrences += 1;
+ }
+ try testing.expectEqual(@as(usize, 1), occurrences);
+
+ if (std.mem.endsWith(u8, file.path, ".gz")) {
+ const base = find(embedded, file.path[0 .. file.path.len - 3]).?;
+ try testing.expect(file.bytes.len < base.bytes.len);
+ // The gzip member header: build-time compression, not an accident.
+ try testing.expectEqual(@as(u8, 0x1f), file.bytes[0]);
+ try testing.expectEqual(@as(u8, 0x8b), file.bytes[1]);
+ }
+ }
}
test "embedded entries agree with the dev-mode content type map" {
diff --git a/web/.prettierignore b/web/.prettierignore
new file mode 100644
index 0000000..993c281
--- /dev/null
+++ b/web/.prettierignore
@@ -0,0 +1,3 @@
+dist/
+dist-placeholder/
+package-lock.json
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..26400f7
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ nxdns
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..b07f7e1
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,3083 @@
+{
+ "name": "nxdns-web",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "nxdns-web",
+ "version": "0.0.0",
+ "dependencies": {
+ "@tanstack/react-query": "5.101.4",
+ "@tanstack/react-router": "1.170.18",
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "4.3.3",
+ "@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",
+ "tailwindcss": "4.3.3",
+ "typescript": "6.0.3",
+ "vite": "8.1.5",
+ "vitest": "4.1.10"
+ },
+ "engines": {
+ "node": ">=24"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
+ "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.0",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
+ "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz",
+ "integrity": "sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.75.0.tgz",
+ "integrity": "sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz",
+ "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.75.0.tgz",
+ "integrity": "sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.75.0.tgz",
+ "integrity": "sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.75.0.tgz",
+ "integrity": "sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.75.0.tgz",
+ "integrity": "sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.75.0.tgz",
+ "integrity": "sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.75.0.tgz",
+ "integrity": "sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.75.0.tgz",
+ "integrity": "sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.75.0.tgz",
+ "integrity": "sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.75.0.tgz",
+ "integrity": "sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.75.0.tgz",
+ "integrity": "sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.75.0.tgz",
+ "integrity": "sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.75.0.tgz",
+ "integrity": "sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.75.0.tgz",
+ "integrity": "sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.75.0.tgz",
+ "integrity": "sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.75.0.tgz",
+ "integrity": "sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.75.0.tgz",
+ "integrity": "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@tanstack/history": {
+ "version": "1.162.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz",
+ "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.101.4",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
+ "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.101.4",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
+ "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.101.4"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@tanstack/react-router": {
+ "version": "1.170.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.18.tgz",
+ "integrity": "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/history": "1.162.0",
+ "@tanstack/react-store": "^0.9.3",
+ "@tanstack/router-core": "1.171.15",
+ "isbot": "^5.1.22"
+ },
+ "engines": {
+ "node": ">=20.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0 || >=19.0.0",
+ "react-dom": ">=18.0.0 || >=19.0.0"
+ }
+ },
+ "node_modules/@tanstack/react-store": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz",
+ "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/store": "0.9.3",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/router-core": {
+ "version": "1.171.15",
+ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.15.tgz",
+ "integrity": "sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/history": "1.162.0",
+ "cookie-es": "^3.0.0",
+ "seroval": "^1.5.4",
+ "seroval-plugins": "^1.5.4"
+ },
+ "engines": {
+ "node": ">=20.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/store": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz",
+ "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.1.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
+ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
+ "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.3",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz",
+ "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isbot": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.1.tgz",
+ "integrity": "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.75.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz",
+ "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.75.0",
+ "@oxlint/binding-android-arm64": "1.75.0",
+ "@oxlint/binding-darwin-arm64": "1.75.0",
+ "@oxlint/binding-darwin-x64": "1.75.0",
+ "@oxlint/binding-freebsd-x64": "1.75.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.75.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.75.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.75.0",
+ "@oxlint/binding-linux-arm64-musl": "1.75.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.75.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.75.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.75.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.75.0",
+ "@oxlint/binding-linux-x64-gnu": "1.75.0",
+ "@oxlint/binding-linux-x64-musl": "1.75.0",
+ "@oxlint/binding-openharmony-arm64": "1.75.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.75.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.75.0",
+ "@oxlint/binding-win32-x64-msvc": "1.75.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=7.0.2001",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.23",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/seroval": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz",
+ "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/seroval-plugins": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz",
+ "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "seroval": "^1.0"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.9",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
+ "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.9"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.9",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
+ "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.17",
+ "rolldown": "~1.1.5",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..ab7e0b6
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "nxdns-web",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "engines": {
+ "node": ">=24"
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "typecheck": "tsc -b",
+ "lint": "oxlint src vite.config.ts",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "test": "vitest run"
+ },
+ "prettier": {
+ "useTabs": true,
+ "tabWidth": 4,
+ "printWidth": 120,
+ "semi": true,
+ "singleQuote": false,
+ "trailingComma": "all"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "5.101.4",
+ "@tanstack/react-router": "1.170.18",
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "4.3.3",
+ "@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",
+ "tailwindcss": "4.3.3",
+ "typescript": "6.0.3",
+ "vite": "8.1.5",
+ "vitest": "4.1.10"
+ }
+}
diff --git a/web/public/favicon.svg b/web/public/favicon.svg
new file mode 100644
index 0000000..b976625
--- /dev/null
+++ b/web/public/favicon.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/src/auth/LoginPage.test.tsx b/web/src/auth/LoginPage.test.tsx
new file mode 100644
index 0000000..4956049
--- /dev/null
+++ b/web/src/auth/LoginPage.test.tsx
@@ -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 = {}): 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(
+
+
+
+
+ ,
+ );
+}
+
+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("/");
+});
diff --git a/web/src/auth/LoginPage.tsx b/web/src/auth/LoginPage.tsx
new file mode 100644
index 0000000..f2c460d
--- /dev/null
+++ b/web/src/auth/LoginPage.tsx
@@ -0,0 +1,117 @@
+import { useEffect, useState, type FormEvent } from "react";
+import { useRouter, useSearch } from "@tanstack/react-router";
+import { ApiError } from "@/lib/api";
+import { useAuth } from "@/auth/store";
+
+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(null);
+ const [busy, setBusy] = useState(false);
+
+ const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
+ const [remaining, setRemaining] = useState(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) {
+ 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 (
+
+
+ nxdns
+ {authRequired !== true ? (
+ Checking whether a password is required…
+ ) : (
+
+ )}
+ {error !== null && (
+
+ {errorMessage(error, remaining)}
+
+ )}
+
+
+ );
+}
diff --git a/web/src/auth/store.test.tsx b/web/src/auth/store.test.tsx
new file mode 100644
index 0000000..693baec
--- /dev/null
+++ b/web/src/auth/store.test.tsx
@@ -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 {children};
+}
+
+function jsonResponse(payload: unknown, status = 200, headers: Record = {}): 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,
+ });
+});
diff --git a/web/src/auth/store.tsx b/web/src/auth/store.tsx
new file mode 100644
index 0000000..d891570
--- /dev/null
+++ b/web/src/auth/store.tsx
@@ -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 | null = null;
+
+function probeAuthRequired(): Promise {
+ 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;
+ login: (password: string) => Promise;
+ /** Ends the session server-side; swallows an already-dead session's 401. */
+ logout: () => Promise;
+}
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [authRequired, setAuthRequired] = useState(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(
+ () => ({ authRequired, probe, login, logout }),
+ [authRequired, probe, login, logout],
+ );
+ return {children};
+}
+
+export function useAuth(): AuthStore {
+ const store = useContext(AuthContext);
+ if (store === null) throw new Error("useAuth requires an AuthProvider");
+ return store;
+}
diff --git a/web/src/features/blocklists/BlocklistForm.tsx b/web/src/features/blocklists/BlocklistForm.tsx
new file mode 100644
index 0000000..6cb5096
--- /dev/null
+++ b/web/src/features/blocklists/BlocklistForm.tsx
@@ -0,0 +1,89 @@
+import { useState, type FormEvent } from "react";
+import InlineError from "@/lib/InlineError";
+import type { Blocklist, BlocklistInput } from "@/lib/types";
+
+const INPUT_CLASS =
+ "mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
+
+interface BlocklistFormProps {
+ initial?: Blocklist;
+ busy: boolean;
+ error: Error | null;
+ onSubmit: (input: BlocklistInput) => Promise;
+ onCancel?: () => void;
+}
+
+export default function BlocklistForm({ initial, busy, 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) {
+ event.preventDefault();
+ try {
+ await onSubmit({ url: url.trim(), name: name.trim(), enabled });
+ if (initial === undefined) {
+ setUrl("");
+ setName("");
+ setEnabled(true);
+ }
+ } catch {
+ // The page renders the mutation error inline below the form.
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/web/src/features/blocklists/BlocklistsPage.test.tsx b/web/src/features/blocklists/BlocklistsPage.test.tsx
new file mode 100644
index 0000000..e7265d0
--- /dev/null
+++ b/web/src/features/blocklists/BlocklistsPage.test.tsx
@@ -0,0 +1,177 @@
+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";
+
+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,
+ skipped_regex_count: 3,
+ 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,
+ skipped_regex_count: 0,
+ checksum: null,
+ },
+ ],
+};
+
+const RESPONSES: Record = {
+ "/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;
+
+beforeEach(() => {
+ resolveUpdate = null;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url === "/api/blocklists/update" && init?.method === "POST") {
+ return new Promise((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() {
+ const queryClient = createQueryClient();
+ const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient);
+ render(
+
+
+
+
+ ,
+ );
+}
+
+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("3")).toBeTruthy();
+ expect(screen.getByText("never")).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,
+ skipped_regex: 4,
+ },
+ {
+ 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,
+ skipped_regex: 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("4")).toBeTruthy();
+ expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
+ expect(screen.getByText(/Update completed/)).toBeTruthy();
+
+ 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.");
+});
diff --git a/web/src/features/blocklists/BlocklistsPage.tsx b/web/src/features/blocklists/BlocklistsPage.tsx
new file mode 100644
index 0000000..31c7617
--- /dev/null
+++ b/web/src/features/blocklists/BlocklistsPage.tsx
@@ -0,0 +1,170 @@
+import { useState } from "react";
+import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+import { formatTime } from "@/lib/format";
+import InlineError from "@/lib/InlineError";
+import {
+ blocklistCreateMutation,
+ blocklistDeleteMutation,
+ blocklistUpdateMutation,
+ blocklistsQuery,
+ blocklistsUpdateNowMutation,
+ queryKeys,
+} from "@/lib/queries";
+import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types";
+import BlocklistForm from "./BlocklistForm";
+import SourceStatusSection from "./SourceStatusSection";
+
+const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
+const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
+
+export default function BlocklistsPage() {
+ const queryClient = useQueryClient();
+ const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
+ const [editing, setEditing] = useState(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));
+
+ // Fed only by the update-now 202 snapshot (no GET exists); the mutation's
+ // state change re-renders this page right after setQueryData runs.
+ const sources = queryClient.getQueryData(queryKeys.blocklistSources);
+ const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
+
+ 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 deleteBlocklist(b: Blocklist) {
+ if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) {
+ remove.mutate(b.id);
+ }
+ }
+
+ const formError = editing === null ? create.error : save.error;
+ const tableError = remove.error ?? toggle.error;
+
+ return (
+
+
+
Blocklists
+
+
+ {updateNow.isSuccess && !updateNow.isPending && (
+
+ Update completed; source status refreshed below.
+
+ )}
+
+
+ {blocklists.length === 0 ? (
+ No blocklist sources yet. Add one below.
+ ) : (
+
+ )}
+
+
+ setEditing(null)}
+ />
+
+
+
+ );
+}
diff --git a/web/src/features/blocklists/SourceStatusSection.tsx b/web/src/features/blocklists/SourceStatusSection.tsx
new file mode 100644
index 0000000..a3948a2
--- /dev/null
+++ b/web/src/features/blocklists/SourceStatusSection.tsx
@@ -0,0 +1,81 @@
+import { formatTime } from "@/lib/format";
+import type { SourceStatus } from "@/lib/types";
+
+const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
+const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
+
+function formatAttempt(unixSeconds: number): string {
+ return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
+}
+
+interface SourceStatusSectionProps {
+ sources: SourceStatus[] | undefined;
+ namesById: ReadonlyMap;
+}
+
+export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
+ return (
+
+ Source status
+ {sources === undefined ? (
+
+ No status snapshot yet — run “Update now” to fetch status for every enabled source.
+
+ ) : sources.length === 0 ? (
+ The last update ran against no enabled sources.
+ ) : (
+
+
+
+
+ | Source |
+ State |
+ Last attempt |
+ Last success |
+ Domains |
+ Wildcards |
+ Skipped regex |
+ Last error |
+
+
+
+ {sources.map((source) => (
+
+ |
+ {namesById.get(source.id) ?? source.url}
+
+ {source.url}
+
+ |
+
+
+ {source.state}
+
+ |
+ {formatAttempt(source.last_attempt)} |
+ {formatAttempt(source.last_success)} |
+ {source.domains} |
+ {source.wildcards} |
+ {source.skipped_regex} |
+
+ {source.last_error === "" ? (
+ —
+ ) : (
+ {source.last_error}
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/features/clients/ClientEditDialog.tsx b/web/src/features/clients/ClientEditDialog.tsx
new file mode 100644
index 0000000..dc87414
--- /dev/null
+++ b/web/src/features/clients/ClientEditDialog.tsx
@@ -0,0 +1,86 @@
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { clientUpdateMutation } from "@/lib/queries";
+import type { Client, Group } from "@/lib/types";
+import InlineError from "@/lib/InlineError";
+
+interface Props {
+ client: Client;
+ groups: Group[];
+ onClose: () => void;
+}
+
+const inputClass =
+ "mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
+
+export default function ClientEditDialog({ client, groups, onClose }: Props) {
+ const queryClient = useQueryClient();
+ const mutation = useMutation(clientUpdateMutation(queryClient));
+ const [name, setName] = useState(client.name);
+ const [groupId, setGroupId] = useState(client.group_id);
+
+ return (
+
+ );
+}
diff --git a/web/src/features/clients/ClientsPage.test.tsx b/web/src/features/clients/ClientsPage.test.tsx
new file mode 100644
index 0000000..76f2174
--- /dev/null
+++ b/web/src/features/clients/ClientsPage.test.tsx
@@ -0,0 +1,125 @@
+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 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",
+ 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 };
+
+function stubFetch(map: Record) {
+ 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) {
+ stubFetch(map);
+ const queryClient = createQueryClient();
+ const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient);
+ render(
+
+
+
+
+ ,
+ );
+ 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("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]!);
+ const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
+ expect(dialog).toBeTruthy();
+ expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
+ expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1");
+});
+
+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("");
+});
diff --git a/web/src/features/clients/ClientsPage.tsx b/web/src/features/clients/ClientsPage.tsx
new file mode 100644
index 0000000..1a25740
--- /dev/null
+++ b/web/src/features/clients/ClientsPage.tsx
@@ -0,0 +1,115 @@
+import { useState } from "react";
+import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+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";
+
+const cellClass = "px-3 py-2";
+const buttonClass =
+ "rounded border border-zinc-300 px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
+
+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(null);
+ const [confirmingId, setConfirmingId] = useState(null);
+
+ return (
+
+ Clients
+ {clients.length === 0 ? (
+
+ No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
+ nothing to create by hand.
+
+ ) : (
+
+
+
+
+ | IP |
+ Name |
+ Group |
+ First seen |
+ Last seen |
+
+ Actions
+ |
+
+
+
+ {clients.map((client) => (
+
+ | {client.ip} |
+
+ {client.name === "" ? — : client.name}
+ {client.hand_edited && (
+
+ edited
+
+ )}
+ |
+ {client.group} |
+ {formatTime(client.first_seen)} |
+ {formatTime(client.last_seen)} |
+
+ {confirmingId === client.id ? (
+
+
+ Deleted clients re-materialize on their next DNS query.
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+
+ {editing !== null && setEditing(null)} />}
+
+
+ );
+}
diff --git a/web/src/features/clients/PrefixesEditor.tsx b/web/src/features/clients/PrefixesEditor.tsx
new file mode 100644
index 0000000..75dee06
--- /dev/null
+++ b/web/src/features/clients/PrefixesEditor.tsx
@@ -0,0 +1,128 @@
+import { useReducer, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { clientPrefixesPutMutation } from "@/lib/queries";
+import type { ClientPrefix, Group } from "@/lib/types";
+import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
+import InlineError from "@/lib/InlineError";
+
+interface Props {
+ prefixes: ClientPrefix[];
+ groups: Group[];
+}
+
+const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
+
+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(null);
+ const dirty = isDirty(state);
+ const defaultGroupId = groups.find((group) => group.id === 1)?.id ?? groups[0]?.id ?? 1;
+
+ 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 (
+
+ Client prefixes
+
+ Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
+ match wins.
+
+ {state.rows.length === 0 ? (
+ No prefixes configured.
+ ) : (
+
+ )}
+ {validation !== null && (
+
+ {validation}
+
+ )}
+
+
+
+
+ {dirty && (
+
+ )}
+
+
+ );
+}
diff --git a/web/src/features/clients/prefixEditor.test.ts b/web/src/features/clients/prefixEditor.test.ts
new file mode 100644
index 0000000..b19ee43
--- /dev/null
+++ b/web/src/features/clients/prefixEditor.test.ts
@@ -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.");
+});
diff --git a/web/src/features/clients/prefixEditor.ts b/web/src/features/clients/prefixEditor.ts
new file mode 100644
index 0000000..59b4a13
--- /dev/null
+++ b/web/src/features/clients/prefixEditor.ts
@@ -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 };
+
+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;
+ });
+}
diff --git a/web/src/features/dashboard/DashboardPage.test.tsx b/web/src/features/dashboard/DashboardPage.test.tsx
new file mode 100644
index 0000000..052f132
--- /dev/null
+++ b/web/src/features/dashboard/DashboardPage.test.tsx
@@ -0,0 +1,163 @@
+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 = {
+ "/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 },
+};
+
+beforeEach(() => {
+ 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();
+});
+
+function renderDashboard() {
+ const queryClient = createQueryClient();
+ const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
+ render(
+
+
+
+
+ ,
+ );
+}
+
+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();
+});
diff --git a/web/src/features/dashboard/DashboardPage.tsx b/web/src/features/dashboard/DashboardPage.tsx
new file mode 100644
index 0000000..6b85341
--- /dev/null
+++ b/web/src/features/dashboard/DashboardPage.tsx
@@ -0,0 +1,99 @@
+import { useState } from "react";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { ApiError } from "@/lib/api";
+import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
+import type { Period } from "@/lib/types";
+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"];
+
+function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
+ return (
+
+ {PERIODS.map((option) => (
+
+ ))}
+
+ );
+}
+
+function InlineError({ error, onRetry }: { error: unknown; onRetry: () => void }) {
+ const message = error instanceof ApiError ? error.message : "request failed";
+ return (
+
+ Failed to load: {message}{" "}
+
+
+ );
+}
+
+function Skeleton({ height }: { height: number }) {
+ return ;
+}
+
+export default function DashboardPage() {
+ const [period, setPeriod] = useState("24h");
+ const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
+ const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
+ const health = useQuery(healthQuery());
+ const upstreamHealth = useQuery(upstreamHealthQuery());
+
+ return (
+
+
+
+ {health.data !== undefined && }
+
+ {stats.isError ? (
+ void stats.refetch()} />
+ ) : stats.data === undefined ? (
+
+ ) : (
+
+ )}
+
+
+
+ Queries over time
+ {timeseries.isError ? (
+ void timeseries.refetch()} />
+ ) : timeseries.data === undefined ? (
+
+ ) : (
+
+ )}
+
+ {health.data === undefined ? : }
+
+
+ {upstreamHealth.isError ? (
+ void upstreamHealth.refetch()} />
+ ) : upstreamHealth.data === undefined ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/web/src/features/dashboard/DiskCard.tsx b/web/src/features/dashboard/DiskCard.tsx
new file mode 100644
index 0000000..73734d7
--- /dev/null
+++ b/web/src/features/dashboard/DiskCard.tsx
@@ -0,0 +1,35 @@
+import { formatBytes } from "@/lib/format";
+import type { Health } from "@/lib/types";
+
+const STATE_CLASSES: Record = {
+ ok: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
+ warn: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
+ critical: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
+};
+
+export default function DiskCard({ disk }: { disk: Health["disk"] }) {
+ return (
+
+
+ Disk
+
+ {disk.state}
+
+
+
+
+
- Free
+ - {formatBytes(disk.free_bytes)}
+
+
+
- Database
+ - {formatBytes(disk.db_bytes)}
+
+
+
- Logs
+ - {formatBytes(disk.log_bytes)}
+
+
+
+ );
+}
diff --git a/web/src/features/dashboard/HealthBanners.tsx b/web/src/features/dashboard/HealthBanners.tsx
new file mode 100644
index 0000000..667eb54
--- /dev/null
+++ b/web/src/features/dashboard/HealthBanners.tsx
@@ -0,0 +1,43 @@
+import { formatBytes } from "@/lib/format";
+import type { Health } from "@/lib/types";
+
+function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
+ const classes =
+ tone === "critical"
+ ? "border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200"
+ : "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200";
+ return (
+
+ {children}
+
+ );
+}
+
+export default function HealthBanners({ health }: { health: Health }) {
+ const banners: React.ReactNode[] = [];
+ if (health.disk.state !== "ok") {
+ banners.push(
+
+ {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.`}
+ ,
+ );
+ }
+ if (health.writer_failed) {
+ banners.push(
+
+ Query log writer failed; new queries are not being persisted.
+ ,
+ );
+ }
+ if (health.queries_dropped > 0) {
+ banners.push(
+
+ {health.queries_dropped.toLocaleString()} queries dropped from the log buffer.
+ ,
+ );
+ }
+ if (banners.length === 0) return null;
+ return {banners}
;
+}
diff --git a/web/src/features/dashboard/StatCards.tsx b/web/src/features/dashboard/StatCards.tsx
new file mode 100644
index 0000000..cd4cb31
--- /dev/null
+++ b/web/src/features/dashboard/StatCards.tsx
@@ -0,0 +1,44 @@
+import { formatMicros } from "@/lib/format";
+import type { StatsTotals } from "@/lib/types";
+
+const numberFormat = new Intl.NumberFormat();
+
+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 (
+
+
{label}
+
+ {value}
+ {detail != null && {detail}}
+
+
+ );
+}
+
+export default function StatCards({ stats }: { stats: StatsTotals }) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/features/dashboard/TimeseriesChart.tsx b/web/src/features/dashboard/TimeseriesChart.tsx
new file mode 100644
index 0000000..2debbdf
--- /dev/null
+++ b/web/src/features/dashboard/TimeseriesChart.tsx
@@ -0,0 +1,219 @@
+import { useEffect, useRef, useState } from "react";
+import { formatTime } from "@/lib/format";
+import type { StatsTimeseries } from "@/lib/types";
+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;
+
+function useContainerWidth(): [React.RefObject, number] {
+ const ref = useRef(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;
+ return (
+
+
{formatTime(bar.bucket.ts)}
+
+
+
- Queries
+ - {bar.bucket.queries}
+
+ {SERIES.map((series) => (
+
+
-
+
+ {series.label}
+
+ - {series.key === "other" ? bar.other : bar.bucket[series.key]}
+
+ ))}
+
+
+ );
+}
+
+export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
+ const [containerRef, measuredWidth] = useContainerWidth();
+ const [hovered, setHovered] = useState(null);
+ const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
+
+ if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
+ return (
+
+ No queries in this period.
+
+ );
+ }
+
+ 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 (
+
+
+ {hoveredBar !== undefined &&
}
+
+ {SERIES.map((series) => (
+ -
+
+ {series.label}
+
+ ))}
+
+
+ Queries per time bucket
+
+
+ | Time |
+ Queries |
+ Blocked |
+ Cached |
+ Other |
+
+
+
+ {layout.bars.map((bar) => (
+
+ | {formatTime(bar.bucket.ts)} |
+ {bar.bucket.queries} |
+ {bar.bucket.blocked} |
+ {bar.bucket.cached} |
+ {bar.other} |
+
+ ))}
+
+
+
+ );
+}
diff --git a/web/src/features/dashboard/UpstreamHealthTable.tsx b/web/src/features/dashboard/UpstreamHealthTable.tsx
new file mode 100644
index 0000000..199689a
--- /dev/null
+++ b/web/src/features/dashboard/UpstreamHealthTable.tsx
@@ -0,0 +1,70 @@
+import type { UpstreamHealth } from "@/lib/types";
+
+function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
+ const bad = value === badValue;
+ return {value ? "yes" : "no"};
+}
+
+export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
+ return (
+
+
+ Upstreams
+
+ {health.available}/{health.total} available
+
+
+ {health.upstreams.length === 0 ? (
+ No upstreams configured.
+ ) : (
+
+
+
+
+ |
+ URL
+ |
+
+ Enabled
+ |
+
+ Available
+ |
+
+ Failures
+ |
+
+ Success rate
+ |
+
+ Last error
+ |
+
+
+
+ {health.upstreams.map((upstream) => (
+
+ | {upstream.url} |
+
+
+ |
+
+
+ |
+ {upstream.total_failures} |
+
+ {(upstream.success_rate * 100).toFixed(1)}%
+ |
+ {upstream.last_error || "—"} |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/features/dashboard/chartLayout.test.ts b/web/src/features/dashboard/chartLayout.test.ts
new file mode 100644
index 0000000..2f08a69
--- /dev/null
+++ b/web/src/features/dashboard/chartLayout.test.ts
@@ -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);
+ });
+});
diff --git a/web/src/features/dashboard/chartLayout.ts b/web/src/features/dashboard/chartLayout.ts
new file mode 100644
index 0000000..2e35611
--- /dev/null
+++ b/web/src/features/dashboard/chartLayout.ts
@@ -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 };
+}
diff --git a/web/src/features/groups/GroupSourcesEditor.tsx b/web/src/features/groups/GroupSourcesEditor.tsx
new file mode 100644
index 0000000..ce791c6
--- /dev/null
+++ b/web/src/features/groups/GroupSourcesEditor.tsx
@@ -0,0 +1,79 @@
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
+import type { Blocklist } from "@/lib/types";
+import { sameSet, toggleSource } from "./sourceSet";
+import InlineError from "@/lib/InlineError";
+
+interface Props {
+ groupId: number;
+ blocklists: Blocklist[];
+}
+
+export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
+ const queryClient = useQueryClient();
+ const sources = useQuery(groupSourcesQuery(groupId));
+ const mutation = useMutation(groupSourcesPutMutation(queryClient));
+ const [selected, setSelected] = useState(null);
+
+ if (sources.isPending) {
+ return (
+
+ Loading sources…
+
+ );
+ }
+ if (sources.isError) return ;
+
+ if (blocklists.length === 0) {
+ return (
+
+ No blocklist sources exist yet — add them on the Blocklists page.
+
+ );
+ }
+
+ const current = selected ?? sources.data;
+ const dirty = !sameSet(current, sources.data);
+
+ return (
+
+
+
+
+
+ {dirty && (
+
+ )}
+
+
+ );
+}
diff --git a/web/src/features/groups/GroupsPage.test.tsx b/web/src/features/groups/GroupsPage.test.tsx
new file mode 100644
index 0000000..ad70b0e
--- /dev/null
+++ b/web/src/features/groups/GroupsPage.test.tsx
@@ -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) {
+ 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) {
+ stubFetch(map);
+ const queryClient = createQueryClient();
+ const router = createAppRouter(createMemoryHistory({ initialEntries: ["/groups"] }), queryClient);
+ render(
+
+
+
+
+ ,
+ );
+ 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);
+});
diff --git a/web/src/features/groups/GroupsPage.tsx b/web/src/features/groups/GroupsPage.tsx
new file mode 100644
index 0000000..504ad5b
--- /dev/null
+++ b/web/src/features/groups/GroupsPage.tsx
@@ -0,0 +1,188 @@
+import { useState } from "react";
+import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+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";
+
+const DEFAULT_GROUP_ID = 1;
+const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
+
+const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
+const buttonClass =
+ "rounded border border-zinc-300 px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 dark:border-zinc-700";
+const primaryButtonClass =
+ "rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
+
+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("");
+
+ return (
+
+ Groups
+
+
+
+ {groups.map((group) => (
+
+ ))}
+
+
+ );
+}
+
+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;
+
+ return (
+
+
+ {renaming ? (
+
+ ) : (
+ {group.name}
+ )}
+
+
+
+ {!renaming && (
+
+ )}
+ {confirming ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ {isDefault && {DEFAULT_GROUP_NOTE}
}
+
+ {expanded && }
+
+ );
+}
diff --git a/web/src/features/groups/sourceSet.test.ts b/web/src/features/groups/sourceSet.test.ts
new file mode 100644
index 0000000..ed09533
--- /dev/null
+++ b/web/src/features/groups/sourceSet.test.ts
@@ -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);
+});
diff --git a/web/src/features/groups/sourceSet.ts b/web/src/features/groups/sourceSet.ts
new file mode 100644
index 0000000..f239b54
--- /dev/null
+++ b/web/src/features/groups/sourceSet.ts
@@ -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]);
+}
diff --git a/web/src/features/live/LiveLogPage.test.tsx b/web/src/features/live/LiveLogPage.test.tsx
new file mode 100644
index 0000000..485a667
--- /dev/null
+++ b/web/src/features/live/LiveLogPage.test.tsx
@@ -0,0 +1,80 @@
+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 = {}): { 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();
+ 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();
+ const blockedRow = screen.getByText("ads.example").closest("tr");
+ expect(blockedRow?.className).toContain("bg-red-50");
+
+ 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();
+});
diff --git a/web/src/features/live/LiveLogPage.tsx b/web/src/features/live/LiveLogPage.tsx
new file mode 100644
index 0000000..58e0864
--- /dev/null
+++ b/web/src/features/live/LiveLogPage.tsx
@@ -0,0 +1,114 @@
+import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
+import { RING_CAPACITY } from "./ringBuffer";
+import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
+
+const buttonClass =
+ "rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
+
+function StatusPill({ status }: { status: StreamStatus }) {
+ const styles: Record = {
+ connecting: ["Connecting…", "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"],
+ open: ["Live", "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"],
+ retrying: ["Reconnecting…", "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200"],
+ capped: ["Disconnected", "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"],
+ };
+ const [label, tone] = styles[status];
+ return (
+
+ {label}
+
+ );
+}
+
+/** 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 (
+
+
+
Live
+
+
+
+
+ {live.frozen && (
+
+ Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
+ kept).
+
+ )}
+
+ {live.missed !== null && (
+
+
+ Stream resumed —{" "}
+ {live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
+
+
+
+ )}
+
+ {live.resyncFailed && (
+
+ Stream resumed, but re-syncing the gap failed — some queries may be missing here.
+
+ )}
+
+ {live.status === "capped" && (
+
+
Live stream unavailable
+
+ The connection failed repeatedly — possibly too many live viewers (the server caps streams per
+ address), or the server is unreachable.
+
+
+
+ )}
+
+ {live.rows.length === 0 ? (
+ live.status !== "capped" && (
+
+ {live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
+
+ )
+ ) : (
+ <>
+
+
+
+
+ {live.rows.map((row) => (
+
+
+
+ ))}
+
+
+
+
+ Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
+ {RING_CAPACITY} kept).
+
+ >
+ )}
+
+ );
+}
diff --git a/web/src/features/live/fakeEventSource.ts b/web/src/features/live/fakeEventSource.ts
new file mode 100644
index 0000000..1737201
--- /dev/null
+++ b/web/src/features/live/fakeEventSource.ts
@@ -0,0 +1,26 @@
+import type { EventSourceLike } from "./useLiveQueries";
+
+/** Test double for the injected EventSource constructor. */
+export class FakeEventSource implements EventSourceLike {
+ readonly url: string;
+ closed = false;
+ private listeners = new Map 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;
+ }
+
+ emit(type: string, event: { data?: unknown } = {}): void {
+ for (const listener of this.listeners.get(type) ?? []) listener(event);
+ }
+}
diff --git a/web/src/features/live/ringBuffer.test.ts b/web/src/features/live/ringBuffer.test.ts
new file mode 100644
index 0000000..86add13
--- /dev/null
+++ b/web/src/features/live/ringBuffer.test.ts
@@ -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 {
+ 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 = {}): LiveRow {
+ return { ...event(ts, domain, overrides), key };
+}
+
+function fetchedRow(id: number, ts: number, domain: string, overrides: Partial = {}): 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]);
+ });
+});
diff --git a/web/src/features/live/ringBuffer.ts b/web/src/features/live/ringBuffer.ts
new file mode 100644
index 0000000..215f3f0
--- /dev/null
+++ b/web/src/features/live/ringBuffer.ts
@@ -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 };
+}
diff --git a/web/src/features/live/useLiveQueries.test.tsx b/web/src/features/live/useLiveQueries.test.tsx
new file mode 100644
index 0000000..1c70ade
--- /dev/null
+++ b/web/src/features/live/useLiveQueries.test.tsx
@@ -0,0 +1,213 @@
+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 = {}): { 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, probeSession?: () => Promise) {
+ 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 => {
+ 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 => 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 => 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 => 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 => 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 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);
+});
diff --git a/web/src/features/live/useLiveQueries.ts b/web/src/features/live/useLiveQueries.ts
new file mode 100644
index 0000000..c840145
--- /dev/null
+++ b/web/src/features/live/useLiveQueries.ts
@@ -0,0 +1,171 @@
+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;
+}
+
+export type EventSourceFactory = (url: string) => EventSourceLike;
+
+export interface LiveQueriesOptions {
+ url?: string;
+ createEventSource?: EventSourceFactory;
+ fetchSince?: (since: number) => Promise;
+ /** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */
+ probeSession?: () => Promise;
+}
+
+// The SSE cap rejection (429) 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.
+export const CAP_ERROR_THRESHOLD = 3;
+
+const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
+const defaultFetchSince = (since: number): Promise => api.getQueries({ since, limit: RING_CAPACITY });
+const defaultProbeSession = (): Promise => 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([]);
+ const [status, setStatus] = useState("connecting");
+ const [missed, setMissed] = useState(null);
+ const [resyncFailed, setResyncFailed] = useState(false);
+ const [frozen, setFrozen] = useState(false);
+ const [frozenRows, setFrozenRows] = useState([]);
+
+ const bufferRef = useRef([]);
+ const keyRef = useRef(0);
+ const lastSeenTsRef = useRef(null);
+ const everOpenRef = useRef(false);
+ const errorsRef = useRef(0);
+ const esRef = useRef(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);
+ });
+
+ es.addEventListener("error", () => {
+ if (esRef.current !== es) return;
+ errorsRef.current += 1;
+ if (errorsRef.current >= CAP_ERROR_THRESHOLD) {
+ 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);
+ } 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);
+ },
+ };
+}
diff --git a/web/src/features/local/LocalDnsPage.test.tsx b/web/src/features/local/LocalDnsPage.test.tsx
new file mode 100644
index 0000000..aa4cbf1
--- /dev/null
+++ b/web/src/features/local/LocalDnsPage.test.tsx
@@ -0,0 +1,85 @@
+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 { LocalRecord, LocalRecordInput } from "@/lib/types";
+
+let records: LocalRecord[];
+let fetchMock: ReturnType;
+
+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 === "/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(
+
+
+
+
+ ,
+ );
+}
+
+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("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" } });
+ fireEvent.change(screen.getByLabelText("Type"), { target: { value: "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" });
+});
diff --git a/web/src/features/local/LocalDnsPage.tsx b/web/src/features/local/LocalDnsPage.tsx
new file mode 100644
index 0000000..48354cd
--- /dev/null
+++ b/web/src/features/local/LocalDnsPage.tsx
@@ -0,0 +1,78 @@
+import { useState } from "react";
+import RecordsTab from "@/features/local/RecordsTab";
+import ZonesTab from "@/features/local/ZonesTab";
+
+type Tab = "records" | "zones";
+
+function TabButton({
+ id,
+ controls,
+ selected,
+ onClick,
+ children,
+}: {
+ id: string;
+ controls: string;
+ selected: boolean;
+ onClick: () => void;
+ children: string;
+}) {
+ return (
+
+ );
+}
+
+export default function LocalDnsPage() {
+ const [tab, setTab] = useState("records");
+
+ return (
+
+ Local DNS
+
+ setTab("records")}
+ >
+ Records
+
+ setTab("zones")}
+ >
+ Forward zones
+
+
+ {tab === "records" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/features/local/RecordsTab.tsx b/web/src/features/local/RecordsTab.tsx
new file mode 100644
index 0000000..9a36044
--- /dev/null
+++ b/web/src/features/local/RecordsTab.tsx
@@ -0,0 +1,247 @@
+import { useId, useState, type FormEvent } from "react";
+import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+import {
+ localRecordCreateMutation,
+ localRecordDeleteMutation,
+ localRecordUpdateMutation,
+ localRecordsQuery,
+} from "@/lib/queries";
+import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
+import InlineError from "@/lib/InlineError";
+
+const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
+
+const inputClass =
+ "mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
+const primaryButtonClass =
+ "rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
+const secondaryButtonClass =
+ "rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
+const rowButtonClass =
+ "rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
+
+type FormState = { mode: "create" } | { mode: "edit"; record: LocalRecord };
+
+function RecordForm({
+ initial,
+ busy,
+ error,
+ onSubmit,
+ onCancel,
+}: {
+ initial?: LocalRecord;
+ busy: boolean;
+ error: unknown;
+ onSubmit: (input: LocalRecordInput) => void;
+ onCancel: () => void;
+}) {
+ const id = useId();
+ const [name, setName] = useState(initial?.name ?? "");
+ const [rtype, setRtype] = useState(initial?.rtype ?? "A");
+ const [value, setValue] = useState(initial?.value ?? "");
+ const [ttl, setTtl] = useState(initial === undefined ? "" : String(initial.ttl));
+
+ function submit(event: FormEvent) {
+ event.preventDefault();
+ const input: LocalRecordInput = { name: name.trim(), rtype, value: value.trim() };
+ if (ttl.trim() !== "") input.ttl = Number(ttl);
+ onSubmit(input);
+ }
+
+ return (
+
+ );
+}
+
+export default function RecordsTab() {
+ const records = useSuspenseQuery(localRecordsQuery()).data;
+ const queryClient = useQueryClient();
+ const create = useMutation(localRecordCreateMutation(queryClient));
+ const update = useMutation(localRecordUpdateMutation(queryClient));
+ const remove = useMutation(localRecordDeleteMutation(queryClient));
+ const [form, setForm] = useState(null);
+
+ function openForm(next: FormState) {
+ create.reset();
+ update.reset();
+ setForm(next);
+ }
+
+ function onSubmit(input: LocalRecordInput) {
+ if (form === null) return;
+ if (form.mode === "create") {
+ create.mutate(input, { onSuccess: () => setForm(null) });
+ } else {
+ update.mutate({ id: form.record.id, input }, { onSuccess: () => setForm(null) });
+ }
+ }
+
+ function onDelete(record: LocalRecord) {
+ if (!window.confirm(`Delete record "${record.name}"?`)) return;
+ remove.mutate(record.id);
+ }
+
+ return (
+
+
+
Answers served directly for LAN names. Changes apply live.
+
+
+
+ {form?.mode === "create" && (
+
setForm(null)}
+ />
+ )}
+
+
+
+
+ |
+ Name
+ |
+
+ Type
+ |
+
+ Value
+ |
+
+ TTL
+ |
+
+ Actions
+ |
+
+
+
+ {records.length === 0 && (
+
+ |
+ No local records yet.
+ |
+
+ )}
+ {records.map((record) => (
+
+ {form?.mode === "edit" && form.record.id === record.id ? (
+ |
+ setForm(null)}
+ />
+ |
+ ) : (
+ <>
+ {record.name} |
+ {record.rtype} |
+ {record.value} |
+ {record.ttl} |
+
+
+
+ |
+ >
+ )}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/web/src/features/local/ZonesTab.tsx b/web/src/features/local/ZonesTab.tsx
new file mode 100644
index 0000000..0849cea
--- /dev/null
+++ b/web/src/features/local/ZonesTab.tsx
@@ -0,0 +1,202 @@
+import { useId, useState, type FormEvent } from "react";
+import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+import {
+ forwardZoneCreateMutation,
+ forwardZoneDeleteMutation,
+ forwardZoneUpdateMutation,
+ forwardZonesQuery,
+} from "@/lib/queries";
+import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
+import InlineError from "@/lib/InlineError";
+
+const inputClass =
+ "mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
+const primaryButtonClass =
+ "rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
+const secondaryButtonClass =
+ "rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
+const rowButtonClass =
+ "rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
+
+type FormState = { mode: "create" } | { mode: "edit"; zone: ForwardZone };
+
+function ZoneForm({
+ initial,
+ busy,
+ error,
+ onSubmit,
+ onCancel,
+}: {
+ initial?: ForwardZone;
+ busy: 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) {
+ event.preventDefault();
+ onSubmit({ zone: zone.trim(), resolver: resolver.trim() });
+ }
+
+ return (
+
+ );
+}
+
+export default function ZonesTab() {
+ const zones = useSuspenseQuery(forwardZonesQuery()).data;
+ const queryClient = useQueryClient();
+ const create = useMutation(forwardZoneCreateMutation(queryClient));
+ const update = useMutation(forwardZoneUpdateMutation(queryClient));
+ const remove = useMutation(forwardZoneDeleteMutation(queryClient));
+ const [form, setForm] = useState(null);
+
+ function openForm(next: FormState) {
+ create.reset();
+ update.reset();
+ setForm(next);
+ }
+
+ function onSubmit(input: ForwardZoneInput) {
+ if (form === null) return;
+ if (form.mode === "create") {
+ create.mutate(input, { onSuccess: () => setForm(null) });
+ } else {
+ update.mutate({ id: form.zone.id, input }, { onSuccess: () => setForm(null) });
+ }
+ }
+
+ function onDelete(zone: ForwardZone) {
+ if (!window.confirm(`Delete forward zone "${zone.zone}"?`)) return;
+ remove.mutate(zone.id);
+ }
+
+ return (
+
+
+
+ Names under these zones go to their own resolver. Changes apply live.
+
+
+
+
+ {form?.mode === "create" && (
+
setForm(null)}
+ />
+ )}
+
+
+
+
+ |
+ Zone
+ |
+
+ Resolver
+ |
+
+ Actions
+ |
+
+
+
+ {zones.length === 0 && (
+
+ |
+ No forward zones yet.
+ |
+
+ )}
+ {zones.map((zone) => (
+
+ {form?.mode === "edit" && form.zone.id === zone.id ? (
+ |
+ setForm(null)}
+ />
+ |
+ ) : (
+ <>
+ {zone.zone} |
+ {zone.resolver} |
+
+
+
+ |
+ >
+ )}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/web/src/features/lookup/LookupPage.test.tsx b/web/src/features/lookup/LookupPage.test.tsx
new file mode 100644
index 0000000..c62e259
--- /dev/null
+++ b/web/src/features/lookup/LookupPage.test.tsx
@@ -0,0 +1,93 @@
+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;
+
+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(
+
+
+
+
+ ,
+ );
+}
+
+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();
+ const select = (await screen.findByLabelText("Group")) as HTMLSelectElement;
+ expect(select.value).toBe("1");
+});
diff --git a/web/src/features/lookup/LookupPage.tsx b/web/src/features/lookup/LookupPage.tsx
new file mode 100644
index 0000000..935c878
--- /dev/null
+++ b/web/src/features/lookup/LookupPage.tsx
@@ -0,0 +1,211 @@
+import { useState, type FormEvent, type ReactNode } from "react";
+import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
+import { ApiError } from "@/lib/api";
+import { groupsQuery, lookupQuery } from "@/lib/queries";
+import type { Group, LookupResult } from "@/lib/types";
+
+const inputClass =
+ "mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
+
+interface Submitted {
+ domain: string;
+ groupId: number;
+}
+
+interface Verdict {
+ label: string;
+ className: string;
+ description: string;
+}
+
+/**
+ * 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",
+ className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300",
+ description: "A local record answers this name directly.",
+ };
+ }
+ if (result.blocked) {
+ return {
+ label: "Blocked",
+ className: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
+ description: "Queries for this name get a blocked response.",
+ };
+ }
+ if (result.forward_zone !== null) {
+ return {
+ label: "Forwarded",
+ className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
+ description: `Queries go to the resolver for zone ${result.forward_zone}.`,
+ };
+ }
+ return {
+ label: "Allowed",
+ className: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300",
+ 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 (
+
+
{label}
+ {children}
+
+ );
+}
+
+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 (
+
+
+
{verdict.label}
+
{verdict.description}
+
+
+
+ {result.domain}
+
+ {groupName}
+ {result.local_records ? "Yes" : "No"}
+
+ {result.forward_zone !== null ? {result.forward_zone} : "—"}
+
+ {result.blocked ? "Yes" : "No"}
+
+ {result.reason}
+
+
+ {result.matched !== "" ? {result.matched} : "—"}
+
+
+ {result.source_url !== null ? (
+
+ {result.source_url}
+
+ ) : (
+ "—"
+ )}
+
+
+ {result.safe_search_rewrite !== null ? (
+ {result.safe_search_rewrite}
+ ) : (
+ "—"
+ )}
+
+
+
+ );
+}
+
+export default function LookupPage() {
+ const groups = useSuspenseQuery(groupsQuery()).data;
+ const defaultGroupId = groups.find((group) => group.id === 1)?.id ?? groups[0]?.id ?? 1;
+
+ const [domain, setDomain] = useState("");
+ const [groupId, setGroupId] = useState(defaultGroupId);
+ const [submitted, setSubmitted] = useState(null);
+
+ const lookup = useQuery({
+ ...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
+ enabled: submitted !== null,
+ });
+
+ function onSubmit(event: FormEvent) {
+ 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 (
+
+ Lookup
+
+ What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
+
+
+ {lookup.isFetching && Looking up…
}
+ {!lookup.isFetching && lookup.isError && (
+
+ {errorMessage(lookup.error)}
+
+ )}
+ {!lookup.isFetching && lookup.data !== undefined && !lookup.isError && (
+
+ )}
+
+ );
+}
diff --git a/web/src/features/pause/PauseWidget.test.tsx b/web/src/features/pause/PauseWidget.test.tsx
new file mode 100644
index 0000000..67dfbb3
--- /dev/null
+++ b/web/src/features/pause/PauseWidget.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+async function findPauseTrigger(): Promise {
+ 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");
+});
diff --git a/web/src/features/pause/PauseWidget.tsx b/web/src/features/pause/PauseWidget.tsx
new file mode 100644
index 0000000..9f00dd7
--- /dev/null
+++ b/web/src/features/pause/PauseWidget.tsx
@@ -0,0 +1,124 @@
+import { useEffect, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { pauseMutation, pauseQuery } from "@/lib/queries";
+import InlineError from "@/lib/InlineError";
+
+const DURATIONS = [
+ { label: "60 seconds", seconds: 60 },
+ { label: "5 minutes", seconds: 300 },
+ { label: "30 minutes", seconds: 1800 },
+ { label: "Indefinitely", seconds: null },
+] as const;
+
+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;
+}
+
+const BUTTON_CLASS =
+ "rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:text-zinc-400 dark:border-zinc-700 dark:disabled:text-zinc-600";
+
+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 (
+
+ );
+ }
+
+ if (data.paused) {
+ return (
+
+
+
+ {data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
+
+
+
+
+
+ );
+ }
+
+ return (
+ {
+ if (e.key === "Escape") setMenuOpen(false);
+ }}
+ >
+
+ {menuOpen && (
+
+ )}
+
+
+ );
+}
diff --git a/web/src/features/queries/QueryLogPage.test.tsx b/web/src/features/queries/QueryLogPage.test.tsx
new file mode 100644
index 0000000..7d4a775
--- /dev/null
+++ b/web/src/features/queries/QueryLogPage.test.tsx
@@ -0,0 +1,257 @@
+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 {
+ 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 = {
+ "/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() {
+ render(
+
+
+ ,
+ );
+}
+
+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((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((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 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();
+});
diff --git a/web/src/features/queries/QueryLogPage.tsx b/web/src/features/queries/QueryLogPage.tsx
new file mode 100644
index 0000000..e187267
--- /dev/null
+++ b/web/src/features/queries/QueryLogPage.tsx
@@ -0,0 +1,261 @@
+import { useRef, useState, type FormEvent } from "react";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import * as api from "@/lib/api";
+import { formatMicros, formatTime } from "@/lib/format";
+import { queriesQuery } from "@/lib/queries";
+import { handleUnauthorized } from "@/lib/queryClient";
+import type { QueriesFilter, QueryRow } from "@/lib/types";
+import { qtypeName } from "./qtype";
+
+const inputClass =
+ "mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
+const buttonClass =
+ "rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
+
+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 }) {
+ if (!row.blocked) return —;
+ return (
+
+
+ Blocked
+
+ {row.block_reason !== "" && {row.block_reason}}
+
+ );
+}
+
+export function QueryCells({ row }: { row: Omit }) {
+ return (
+ <>
+ {formatTime(row.ts)} |
+ {row.domain} |
+ {row.client_ip} |
+ {qtypeName(row.qtype)} |
+
+
+ |
+
+ {row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
+ |
+
+ {row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
+ |
+ {row.upstream === "" ? "—" : row.upstream} |
+ >
+ );
+}
+
+export function QueryTableHead() {
+ const th = "px-3 py-2 font-medium text-zinc-600 dark:text-zinc-400";
+ return (
+
+
+ | Time |
+ Domain |
+ Client |
+ Type |
+ Status |
+ Response |
+ Cache |
+ Upstream |
+
+
+ );
+}
+
+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({});
+ const [extra, setExtra] = useState([]);
+ const [cursorOverride, setCursorOverride] = useState(undefined);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [moreError, setMoreError] = useState(null);
+ const generation = useRef(0);
+
+ const base = useQuery({ ...queriesQuery(applied), placeholderData: keepPreviousData });
+
+ const rows: QueryRow[] = [...(base.data?.queries ?? []), ...extra];
+ const nextBefore = cursorOverride !== undefined ? cursorOverride : (base.data?.next_before ?? null);
+ const filterActive = Object.keys(applied).length > 0;
+
+ function resetAccumulation() {
+ generation.current += 1;
+ setExtra([]);
+ setCursorOverride(undefined);
+ setLoadingMore(false);
+ setMoreError(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);
+ resetAccumulation();
+ }
+
+ function clearFilters() {
+ setDomain("");
+ setClient("");
+ setBlocked("any");
+ setSince("");
+ setUntil("");
+ setApplied({});
+ resetAccumulation();
+ }
+
+ function loadMore() {
+ if (nextBefore === null || loadingMore || base.isPlaceholderData) return;
+ const startedGeneration = generation.current;
+ setLoadingMore(true);
+ setMoreError(null);
+ api.getQueries({ ...applied, before: nextBefore })
+ .then((page) => {
+ if (generation.current !== startedGeneration) return;
+ setExtra((prev) => [...prev, ...page.queries]);
+ setCursorOverride(page.next_before);
+ })
+ .catch((error: unknown) => {
+ if (error instanceof api.ApiError && error.status === 401) {
+ handleUnauthorized(error);
+ return;
+ }
+ if (generation.current !== startedGeneration) return;
+ setMoreError(error instanceof Error ? error.message : String(error));
+ })
+ .finally(() => {
+ if (generation.current !== startedGeneration) return;
+ setLoadingMore(false);
+ });
+ }
+
+ return (
+
+ Query Log
+
+
+
+ {base.data === undefined ? (
+
+ Loading query log…
+
+ ) : rows.length === 0 ? (
+
+ {filterActive ? "No queries match the current filters." : "No queries logged yet."}
+
+ ) : (
+ <>
+
+
+
+
+ {rows.map((row) => (
+
+
+
+ ))}
+
+
+
+
+
+ Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
+ {nextBefore === null ? " — end of log" : ""}
+
+ {nextBefore !== null && (
+
+ )}
+
+ {moreError !== null && (
+
+ Failed to load more: {moreError}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/web/src/features/queries/qtype.test.ts b/web/src/features/queries/qtype.test.ts
new file mode 100644
index 0000000..d316e3c
--- /dev/null
+++ b/web/src/features/queries/qtype.test.ts
@@ -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", () => {
+ expect(qtypeName(99)).toBe("TYPE99");
+ expect(qtypeName(0)).toBe("TYPE0");
+});
+
+test("null qtype renders as a dash", () => {
+ expect(qtypeName(null)).toBe("—");
+});
diff --git a/web/src/features/queries/qtype.ts b/web/src/features/queries/qtype.ts
new file mode 100644
index 0000000..615614a
--- /dev/null
+++ b/web/src/features/queries/qtype.ts
@@ -0,0 +1,27 @@
+const QTYPE_NAMES: Record = {
+ 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` 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}`;
+}
diff --git a/web/src/features/rules/RulesPage.test.tsx b/web/src/features/rules/RulesPage.test.tsx
new file mode 100644
index 0000000..6dc9c31
--- /dev/null
+++ b/web/src/features/rules/RulesPage.test.tsx
@@ -0,0 +1,108 @@
+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 RESPONSES: Record = {
+ "/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/groups": {
+ groups: [
+ { id: 1, name: "Default", safe_search: false },
+ { id: 2, name: "Kids", safe_search: true },
+ ],
+ },
+ "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
+};
+
+beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url === "/api/rules" && init?.method === "POST") {
+ return new Response(JSON.stringify({ error: "rate limited" }), {
+ status: 429,
+ headers: { "content-type": "application/json", "Retry-After": "5" },
+ });
+ }
+ 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 renderRulesRoute() {
+ const queryClient = createQueryClient();
+ const router = createAppRouter(createMemoryHistory({ initialEntries: ["/rules"] }), queryClient);
+ render(
+
+
+
+
+ ,
+ );
+}
+
+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);
+
+ const kindSelect = screen.getByLabelText("Kind") as HTMLSelectElement;
+ expect(Array.from(kindSelect.options).map((o) => o.value)).toEqual(["exact", "wildcard"]);
+
+ const actionSelect = screen.getByLabelText("Action") as HTMLSelectElement;
+ expect(Array.from(actionSelect.options).map((o) => o.value)).toEqual(["allow", "block"]);
+
+ const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
+ expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Default", "Kids"]);
+});
+
+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.");
+});
diff --git a/web/src/features/rules/RulesPage.tsx b/web/src/features/rules/RulesPage.tsx
new file mode 100644
index 0000000..0b7eb14
--- /dev/null
+++ b/web/src/features/rules/RulesPage.tsx
@@ -0,0 +1,171 @@
+import { useState, type FormEvent } from "react";
+import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+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";
+
+const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
+const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
+const INPUT_CLASS =
+ "mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
+
+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("exact");
+ const [action, setAction] = useState("block");
+ const [groupId, setGroupId] = useState(groups[0]?.id ?? 1);
+
+ function onSubmit(event: FormEvent) {
+ event.preventDefault();
+ create.mutate(
+ { group_id: groupId, pattern: pattern.trim(), kind, action },
+ { onSuccess: () => setPattern("") },
+ );
+ }
+
+ function deleteRule(rule: Rule) {
+ if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) {
+ remove.mutate(rule.id);
+ }
+ }
+
+ return (
+
+ Rules
+
+ {rules.length === 0 ? (
+ No allow or block rules yet. Create one below.
+ ) : (
+
+
+
+
+ | Pattern |
+ Kind |
+ Action |
+ Group |
+ Created |
+
+ Actions
+ |
+
+
+
+ {rules.map((rule) => (
+
+ | {rule.pattern} |
+ {rule.kind} |
+
+
+ {rule.action}
+
+ |
+ {rule.group} |
+ {formatTime(rule.created_at)} |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/features/settings/RestartBanner.test.tsx b/web/src/features/settings/RestartBanner.test.tsx
new file mode 100644
index 0000000..6f7933d
--- /dev/null
+++ b/web/src/features/settings/RestartBanner.test.tsx
@@ -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();
+ 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();
+ act(() => raiseRestartBanner());
+ act(() => raiseRestartBanner());
+ expect(screen.getByRole("status")).toBeTruthy();
+});
diff --git a/web/src/features/settings/RestartBanner.tsx b/web/src/features/settings/RestartBanner.tsx
new file mode 100644
index 0000000..05dc4ae
--- /dev/null
+++ b/web/src/features/settings/RestartBanner.tsx
@@ -0,0 +1,21 @@
+import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
+
+export default function RestartBanner() {
+ const raised = useRestartBanner();
+ if (!raised) return null;
+ return (
+
+ Settings saved. Restart nxdns to apply.
+
+
+ );
+}
diff --git a/web/src/features/settings/SettingsPage.test.tsx b/web/src/features/settings/SettingsPage.test.tsx
new file mode 100644
index 0000000..cfb0e12
--- /dev/null
+++ b/web/src/features/settings/SettingsPage.test.tsx
@@ -0,0 +1,234 @@
+import { Suspense } from "react";
+import { QueryClientProvider } 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 type { Settings, SettingsPatch } from "@/lib/types";
+
+function baseSettings(): Settings {
+ return {
+ runtime: { io_backend: "threaded" },
+ upstream: { connect_timeout_ms: 2000, 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,
+ 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;
+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>;
+ for (const [section, fields] of Object.entries(patch)) {
+ for (const [key, value] of Object.entries(fields as Record)) {
+ 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() {
+ render(
+
+
+ loading}>
+
+
+ ,
+ );
+ await screen.findByRole("heading", { name: "Settings" });
+}
+
+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" });
+ fireEvent.change(within(logging).getByLabelText("level"), { target: { value: "debug" } });
+ 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((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);
+});
diff --git a/web/src/features/settings/SettingsPage.tsx b/web/src/features/settings/SettingsPage.tsx
new file mode 100644
index 0000000..59a9861
--- /dev/null
+++ b/web/src/features/settings/SettingsPage.tsx
@@ -0,0 +1,329 @@
+import { useState, type FormEvent } from "react";
+import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+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";
+
+/** 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 as Record).some((key) => !(section === "web" && key === "password")),
+ );
+}
+
+interface FieldDef {
+ key: string;
+ kind: "number" | "text" | "boolean" | readonly string[];
+}
+
+interface SectionDef {
+ section: keyof Settings;
+ title: string;
+ fields: readonly FieldDef[];
+}
+
+const TLS_FIELDS: readonly FieldDef[] = [
+ { 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 SectionDef[] = [
+ { section: "runtime", title: "Runtime", fields: [{ key: "io_backend", kind: ["threaded", "evented"] }] },
+ {
+ section: "upstream",
+ title: "Upstream",
+ fields: [
+ { key: "connect_timeout_ms", kind: "number" },
+ { key: "read_timeout_ms", kind: "number" },
+ { key: "total_timeout_ms", kind: "number" },
+ ],
+ },
+ {
+ 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" },
+ ],
+ },
+ {
+ section: "blocking",
+ title: "Blocking",
+ fields: [
+ { key: "response", kind: ["zero", "nxdomain"] },
+ { key: "ttl", kind: "number" },
+ ],
+ },
+ {
+ section: "cache",
+ title: "Cache",
+ fields: [
+ { key: "size", kind: "number" },
+ { key: "negative_ttl_max", kind: "number" },
+ ],
+ },
+ {
+ 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" },
+ ],
+ },
+ { section: "doh_server", title: "DoH Server", fields: TLS_FIELDS },
+ { section: "dot_server", title: "DoT Server", fields: TLS_FIELDS },
+ { section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] },
+ {
+ 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" },
+ ],
+ },
+ {
+ section: "disk",
+ title: "Disk",
+ fields: [
+ { key: "min_free_mb", kind: "number" },
+ { key: "warn_free_mb", kind: "number" },
+ ],
+ },
+ {
+ section: "blocklist_update",
+ title: "Blocklist Update",
+ fields: [
+ { key: "enabled", kind: "boolean" },
+ { key: "interval_hours", kind: "number" },
+ ],
+ },
+];
+
+const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300";
+const INPUT_CLASS =
+ "rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
+
+function FieldRow({
+ section,
+ def,
+ value,
+ onChange,
+}: {
+ section: string;
+ def: FieldDef;
+ value: unknown;
+ onChange: (value: unknown) => void;
+}) {
+ const id = `${section}.${def.key}`;
+ if (def.kind === "boolean") {
+ return (
+
+ onChange(e.target.checked)}
+ className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
+ />
+
+
+ );
+ }
+ if (Array.isArray(def.kind)) {
+ return (
+
+
+
+
+ );
+ }
+ if (def.kind === "number") {
+ const numeric = value as number;
+ return (
+
+
+ onChange(e.target.valueAsNumber)}
+ className={INPUT_CLASS}
+ />
+
+ );
+ }
+ return (
+
+
+ onChange(e.target.value)}
+ className={INPUT_CLASS}
+ />
+
+ );
+}
+
+export default function SettingsPage() {
+ const { data } = useSuspenseQuery(settingsQuery());
+ const queryClient = useQueryClient();
+ const mutation = useMutation(settingsPutMutation(queryClient));
+ const [edited, setEdited] = useState(() => structuredClone(data.settings));
+ const [password, setPassword] = useState("");
+ const [confirm, setConfirm] = useState("");
+
+ const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm;
+ const hasInvalidNumber = SECTIONS.some(({ section, fields }) =>
+ fields.some(
+ (field) => field.kind === "number" && Number.isNaN((edited[section] as Record)[field.key]),
+ ),
+ );
+ const patch = buildSettingsPatch(data.settings, edited, password === "" ? undefined : password);
+ const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
+
+ function setField(section: keyof Settings, key: string, value: unknown): void {
+ setEdited((prev) => ({
+ ...prev,
+ [section]: { ...(prev[section] as Record), [key]: value },
+ }));
+ }
+
+ function handleSubmit(event: FormEvent): void {
+ event.preventDefault();
+ if (patch === null || passwordsMismatch || hasInvalidNumber) return;
+ const restartNeeded = patchRequiresRestart(patch);
+ mutation.mutate(patch, {
+ onSuccess: (envelope) => {
+ setEdited(structuredClone(envelope.settings));
+ setPassword("");
+ setConfirm("");
+ if (restartNeeded) raiseRestartBanner();
+ },
+ });
+ }
+
+ return (
+
+ Settings
+
+ Changes are validated as a whole; every setting requires a restart to take effect.
+
+
+
+ );
+}
diff --git a/web/src/features/settings/restartBanner.ts b/web/src/features/settings/restartBanner.ts
new file mode 100644
index 0000000..7fdd0d4
--- /dev/null
+++ b/web/src/features/settings/restartBanner.ts
@@ -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);
+}
diff --git a/web/src/lib/InlineError.tsx b/web/src/lib/InlineError.tsx
new file mode 100644
index 0000000..f377520
--- /dev/null
+++ b/web/src/lib/InlineError.tsx
@@ -0,0 +1,39 @@
+import { useEffect, useState } from "react";
+import { ApiError } from "@/lib/api";
+
+/** Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with countdown. */
+export default function InlineError({ error }: { error: unknown }) {
+ const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
+ const [remaining, setRemaining] = useState(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 (
+
+ {message}
+
+ );
+}
diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts
new file mode 100644
index 0000000..8ed6f49
--- /dev/null
+++ b/web/src/lib/api.test.ts
@@ -0,0 +1,96 @@
+import { ApiError, deleteGroup, getQueries, getStats, listGroups, login, putGroupSources } from "@/lib/api";
+
+function jsonResponse(payload: unknown, status = 200, headers: Record = {}): Response {
+ return new Response(JSON.stringify(payload), {
+ status,
+ headers: { "content-type": "application/json", ...headers },
+ });
+}
+
+const fetchMock = vi.fn();
+
+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;
+ 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("bad gateway", { 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] }));
+});
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 0000000..620f875
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,235 @@
+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 {
+ 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(path: string, init?: { method?: string; body?: unknown }): Promise {
+ 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;
+}
+
+async function requestText(path: string): Promise {
+ const res = await fetch(path, { credentials: "same-origin" });
+ if (!res.ok) throw await toApiError(res);
+ return res.text();
+}
+
+type QueryValue = string | number | boolean | undefined;
+
+function qs(params: Record): 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 getMetrics = (): Promise => requestText("/metrics");
+export const getHealth = (): Promise => request("/api/health");
+export const getVersion = (): Promise => request("/api/version");
+export const getOpenapiYaml = (): Promise => requestText("/api/openapi.yaml");
+
+// Auth
+
+export const login = (body: LoginRequest): Promise =>
+ request("/api/auth/login", { method: "POST", body });
+export const logout = (): Promise => request("/api/auth/logout", { method: "POST", body: {} });
+
+// Query log + stats
+
+export const getQueries = (filter: QueriesFilter = {}): Promise =>
+ 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 => request(`/api/stats${qs({ period })}`);
+export const getStatsTimeseries = (period?: Period): Promise =>
+ request(`/api/stats/timeseries${qs({ period })}`);
+
+export const getLookup = (domain: string, groupId?: number): Promise =>
+ request(`/api/lookup${qs({ domain, group_id: groupId })}`);
+
+export const getUpstreamHealth = (): Promise => request("/api/upstream/health");
+
+// Groups
+
+export const listGroups = async (): Promise => (await request<{ groups: Group[] }>("/api/groups")).groups;
+export const createGroup = (input: GroupInput): Promise =>
+ request("/api/groups", { method: "POST", body: input });
+export const getGroup = (id: number): Promise => request(`/api/groups/${id}`);
+export const updateGroup = (id: number, input: GroupInput): Promise =>
+ request(`/api/groups/${id}`, { method: "PUT", body: input });
+export const deleteGroup = (id: number): Promise => request(`/api/groups/${id}`, { method: "DELETE" });
+
+export const getGroupSources = async (id: number): Promise =>
+ (await request<{ source_ids: number[] }>(`/api/groups/${id}/sources`)).source_ids;
+export const putGroupSources = async (id: number, sourceIds: number[]): Promise =>
+ (
+ await request<{ source_ids: number[] }>(`/api/groups/${id}/sources`, {
+ method: "PUT",
+ body: { source_ids: sourceIds },
+ })
+ ).source_ids;
+
+// Blocklists
+
+export const listBlocklists = async (): Promise =>
+ (await request<{ blocklists: Blocklist[] }>("/api/blocklists")).blocklists;
+export const createBlocklist = (input: BlocklistInput): Promise =>
+ request("/api/blocklists", { method: "POST", body: input });
+export const updateBlocklistsNow = async (): Promise =>
+ (await request<{ sources: SourceStatus[] }>("/api/blocklists/update", { method: "POST", body: {} })).sources;
+export const getBlocklist = (id: number): Promise => request(`/api/blocklists/${id}`);
+export const updateBlocklist = (id: number, input: BlocklistInput): Promise =>
+ request(`/api/blocklists/${id}`, { method: "PUT", body: input });
+export const deleteBlocklist = (id: number): Promise => request(`/api/blocklists/${id}`, { method: "DELETE" });
+
+// Rules
+
+export const listRules = async (): Promise => (await request<{ rules: Rule[] }>("/api/rules")).rules;
+export const createRule = (input: RuleInput): Promise =>
+ request("/api/rules", { method: "POST", body: input });
+export const getRule = (id: number): Promise => request(`/api/rules/${id}`);
+export const updateRule = (id: number, input: RuleInput): Promise =>
+ request(`/api/rules/${id}`, { method: "PUT", body: input });
+export const deleteRule = (id: number): Promise => request(`/api/rules/${id}`, { method: "DELETE" });
+
+// Local records
+
+export const listLocalRecords = async (): Promise =>
+ (await request<{ local_records: LocalRecord[] }>("/api/local-records")).local_records;
+export const createLocalRecord = (input: LocalRecordInput): Promise =>
+ request("/api/local-records", { method: "POST", body: input });
+export const getLocalRecord = (id: number): Promise => request(`/api/local-records/${id}`);
+export const updateLocalRecord = (id: number, input: LocalRecordInput): Promise =>
+ request(`/api/local-records/${id}`, { method: "PUT", body: input });
+export const deleteLocalRecord = (id: number): Promise =>
+ request(`/api/local-records/${id}`, { method: "DELETE" });
+
+// Forward zones
+
+export const listForwardZones = async (): Promise =>
+ (await request<{ forward_zones: ForwardZone[] }>("/api/forward-zones")).forward_zones;
+export const createForwardZone = (input: ForwardZoneInput): Promise =>
+ request("/api/forward-zones", { method: "POST", body: input });
+export const getForwardZone = (id: number): Promise => request(`/api/forward-zones/${id}`);
+export const updateForwardZone = (id: number, input: ForwardZoneInput): Promise =>
+ request(`/api/forward-zones/${id}`, { method: "PUT", body: input });
+export const deleteForwardZone = (id: number): Promise =>
+ request(`/api/forward-zones/${id}`, { method: "DELETE" });
+
+// Clients + prefixes
+
+export const listClients = async (): Promise =>
+ (await request<{ clients: Client[] }>("/api/clients")).clients;
+export const getClient = (id: number): Promise => request(`/api/clients/${id}`);
+export const updateClient = (id: number, edit: ClientEdit): Promise =>
+ request(`/api/clients/${id}`, { method: "PUT", body: edit });
+export const deleteClient = (id: number): Promise => request(`/api/clients/${id}`, { method: "DELETE" });
+
+export const listClientPrefixes = async (): Promise =>
+ (await request<{ client_prefixes: ClientPrefix[] }>("/api/client-prefixes")).client_prefixes;
+export const putClientPrefixes = async (prefixes: ClientPrefixInput[]): Promise =>
+ (
+ await request<{ client_prefixes: ClientPrefix[] }>("/api/client-prefixes", {
+ method: "PUT",
+ body: { client_prefixes: prefixes },
+ })
+ ).client_prefixes;
+
+// Upstreams
+
+export const listUpstreams = async (): Promise =>
+ (await request<{ upstreams: Upstream[] }>("/api/upstreams")).upstreams;
+export const createUpstream = (input: UpstreamInput): Promise =>
+ request("/api/upstreams", { method: "POST", body: input });
+export const getUpstream = (id: number): Promise => request(`/api/upstreams/${id}`);
+export const updateUpstream = (id: number, input: UpstreamInput): Promise =>
+ request(`/api/upstreams/${id}`, { method: "PUT", body: input });
+export const deleteUpstream = (id: number): Promise => request(`/api/upstreams/${id}`, { method: "DELETE" });
+
+// Pause + settings
+
+export const getPause = (): Promise => request("/api/pause");
+export const postPause = (body: PausePost): Promise => request("/api/pause", { method: "POST", body });
+
+export const getSettings = (): Promise => request("/api/settings");
+export const putSettings = (patch: SettingsPatch): Promise =>
+ request("/api/settings", { method: "PUT", body: patch });
diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts
new file mode 100644
index 0000000..38cda20
--- /dev/null
+++ b/web/src/lib/format.test.ts
@@ -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");
+});
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts
new file mode 100644
index 0000000..271a0aa
--- /dev/null
+++ b/web/src/lib/format.ts
@@ -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`;
+}
diff --git a/web/src/lib/queries.ts b/web/src/lib/queries.ts
new file mode 100644
index 0000000..ba3d1b8
--- /dev/null
+++ b/web/src/lib/queries.ts
@@ -0,0 +1,279 @@
+import { queryOptions, type QueryClient } from "@tanstack/react-query";
+import * as api from "@/lib/api";
+import type {
+ BlocklistInput,
+ ClientEdit,
+ ClientPrefixInput,
+ ForwardZoneInput,
+ GroupInput,
+ LocalRecordInput,
+ PausePost,
+ Period,
+ QueriesFilter,
+ 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,
+ queries: (filter: QueriesFilter) => ["queries", filter] as const,
+ upstreamHealth: ["upstream-health"] as const,
+ lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
+ groups: ["groups"] as const,
+ groupSources: (id: number) => ["groups", id, "sources"] as const,
+ blocklists: ["blocklists"] as const,
+ /** Fed only by POST /api/blocklists/update's 202 snapshot; no GET exists. */
+ blocklistSources: ["blocklists", "sources"] 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,
+ });
+
+export const queriesQuery = (filter: QueriesFilter = {}) =>
+ queryOptions({ queryKey: queryKeys.queries(filter), queryFn: () => api.getQueries(filter) });
+
+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 {
+ return Promise.all([
+ qc.invalidateQueries({ queryKey: queryKeys.groups }),
+ qc.invalidateQueries({ queryKey: ["lookup"] }),
+ 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: ["lookup"] });
+ },
+});
+
+function invalidateBlocklistWorld(qc: QueryClient): Promise {
+ return Promise.all([
+ qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
+ qc.invalidateQueries({ queryKey: ["lookup"] }),
+ qc.invalidateQueries({ queryKey: ["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 sources cache; counters refresh. */
+export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
+ mutationFn: () => api.updateBlocklistsNow(),
+ onSuccess: (sources: Awaited>) => {
+ qc.setQueryData(queryKeys.blocklistSources, sources);
+ return Promise.all([
+ qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
+ qc.invalidateQueries({ queryKey: ["lookup"] }),
+ ]);
+ },
+});
+
+function invalidateRules(qc: QueryClient): Promise {
+ return Promise.all([
+ qc.invalidateQueries({ queryKey: queryKeys.rules }),
+ qc.invalidateQueries({ queryKey: ["lookup"] }),
+ ]);
+}
+
+export const ruleCreateMutation = (qc: QueryClient) => ({
+ mutationFn: (input: RuleInput) => api.createRule(input),
+ onSuccess: () => invalidateRules(qc),
+});
+
+export const ruleUpdateMutation = (qc: QueryClient) => ({
+ mutationFn: ({ id, input }: { id: number; input: RuleInput }) => api.updateRule(id, input),
+ onSuccess: () => invalidateRules(qc),
+});
+
+export const ruleDeleteMutation = (qc: QueryClient) => ({
+ mutationFn: (id: number) => api.deleteRule(id),
+ onSuccess: () => invalidateRules(qc),
+});
+
+function invalidateLocalRecords(qc: QueryClient): Promise {
+ return Promise.all([
+ qc.invalidateQueries({ queryKey: queryKeys.localRecords }),
+ qc.invalidateQueries({ queryKey: ["lookup"] }),
+ ]);
+}
+
+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 {
+ return Promise.all([
+ qc.invalidateQueries({ queryKey: queryKeys.forwardZones }),
+ qc.invalidateQueries({ queryKey: ["lookup"] }),
+ ]);
+}
+
+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>) => {
+ qc.setQueryData(queryKeys.clientPrefixes, stored);
+ },
+});
+
+function invalidateUpstreams(qc: QueryClient): Promise {
+ 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>) => {
+ qc.setQueryData(queryKeys.pause, state);
+ },
+});
+
+export const settingsPutMutation = (qc: QueryClient) => ({
+ mutationFn: (patch: SettingsPatch) => api.putSettings(patch),
+ onSuccess: (envelope: Awaited>) => {
+ qc.setQueryData(queryKeys.settings, envelope);
+ return qc.invalidateQueries({ queryKey: queryKeys.settings });
+ },
+});
diff --git a/web/src/lib/queryClient.ts b/web/src/lib/queryClient.ts
new file mode 100644
index 0000000..605c4bf
--- /dev/null
+++ b/web/src/lib/queryClient.ts
@@ -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,
+ },
+ },
+ });
+}
diff --git a/web/src/lib/settingsDiff.test.ts b/web/src/lib/settingsDiff.test.ts
new file mode 100644
index 0000000..c4d7369
--- /dev/null
+++ b/web/src/lib/settingsDiff.test.ts
@@ -0,0 +1,104 @@
+import { buildSettingsPatch } from "@/lib/settingsDiff";
+import type { Settings } from "@/lib/types";
+
+function baseSettings(): Settings {
+ return {
+ runtime: { io_backend: "threaded" },
+ upstream: { connect_timeout_ms: 2000, 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,
+ 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" },
+ });
+});
diff --git a/web/src/lib/settingsDiff.ts b/web/src/lib/settingsDiff.ts
new file mode 100644
index 0000000..ef34628
--- /dev/null
+++ b/web/src/lib/settingsDiff.ts
@@ -0,0 +1,40 @@
+import type { Settings, SettingsPatch } from "@/lib/types";
+
+const SECTIONS = [
+ "runtime",
+ "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> = {};
+ for (const section of SECTIONS) {
+ const before = original[section] as Record;
+ const after = edited[section] as Record;
+ let changed: Record | 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);
+}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
new file mode 100644
index 0000000..d1b83ed
--- /dev/null
+++ b/web/src/lib/types.ts
@@ -0,0 +1,403 @@
+// 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";
+
+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;
+
+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;
+ skipped_regex_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;
+ skipped_regex: number;
+}
+
+export type RuleKind = "exact" | "wildcard";
+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;
+ 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 {
+ runtime: {
+ io_backend: "threaded" | "evented";
+ };
+ upstream: {
+ connect_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;
+ /** 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;
+ };
+}
+
+export interface SettingsEnvelope {
+ settings: Settings;
+ restart_required: string[];
+}
+
+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 {
+ runtime?: Partial;
+ upstream?: Partial;
+ dns?: Partial;
+ blocking?: Partial;
+ cache?: Partial;
+ web?: Partial> & { password?: string };
+ doh_server?: TlsListenerPatch;
+ dot_server?: TlsListenerPatch;
+ edns?: Partial;
+ logging?: Partial;
+ disk?: Partial;
+ blocklist_update?: Partial;
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..ce7b9b5
--- /dev/null
+++ b/web/src/main.tsx
@@ -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(
+
+
+
+
+
+
+ ,
+);
diff --git a/web/src/routes.tsx b/web/src/routes.tsx
new file mode 100644
index 0000000..8b8483e
--- /dev/null
+++ b/web/src/routes.tsx
@@ -0,0 +1,218 @@
+import type { QueryClient } from "@tanstack/react-query";
+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,
+ queriesQuery,
+ rulesQuery,
+ settingsQuery,
+ statsQuery,
+ timeseriesQuery,
+ upstreamHealthQuery,
+} from "@/lib/queries";
+
+export interface RouterContext {
+ queryClient: QueryClient;
+}
+
+function RoutePending() {
+ return (
+
+ Loading…
+
+ );
+}
+
+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 (
+
+
{title}
+
{detail}
+
+
+ );
+}
+
+const rootRoute = createRootRouteWithContext()();
+
+const loginRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/login",
+ validateSearch: (search: Record): { 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: "/",
+ loader: ({ context }) =>
+ Promise.all([
+ 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.ensureQueryData(queriesQuery({})),
+ 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 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,
+ 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;
+ }
+}
diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx
new file mode 100644
index 0000000..0501d3f
--- /dev/null
+++ b/web/src/shell/AppShell.test.tsx
@@ -0,0 +1,125 @@
+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",
+ "Lookup",
+ "Settings",
+];
+
+const RESPONSES: Record = {
+ "/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(
+
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+
+ ,
+ );
+
+ 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();
+});
diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx
new file mode 100644
index 0000000..96473df
--- /dev/null
+++ b/web/src/shell/AppShell.tsx
@@ -0,0 +1,127 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Link, Outlet, useNavigate } from "@tanstack/react-router";
+import { useAuth } from "@/auth/store";
+import InlineError from "@/lib/InlineError";
+import { versionQuery } from "@/lib/queries";
+import PauseWidget from "../features/pause/PauseWidget";
+import RestartBanner from "../features/settings/RestartBanner";
+
+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: "/lookup", label: "Lookup" },
+ { to: "/settings", label: "Settings" },
+] as const;
+
+function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
+ return (
+
+ {NAV_ITEMS.map((item) => (
+ -
+
+ {item.label}
+
+
+ ))}
+
+ );
+}
+
+function VersionFooter() {
+ const { data } = useQuery(versionQuery());
+ return (
+
+ );
+}
+
+function LogoutButton() {
+ const { authRequired, logout } = useAuth();
+ const navigate = useNavigate();
+ const [error, setError] = useState(null);
+ if (authRequired !== true) return null;
+ return (
+
+
+ {error !== null && }
+
+ );
+}
+
+export default function AppShell() {
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ return (
+
+
+
+
+
+ nxdns
+
+
+
+ {drawerOpen && (
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/web/src/styles.css b/web/src/styles.css
new file mode 100644
index 0000000..f1d8c73
--- /dev/null
+++ b/web/src/styles.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json
new file mode 100644
index 0000000..a117ef1
--- /dev/null
+++ b/web/tsconfig.app.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2022",
+ "lib": ["es2023", "dom", "dom.iterable"],
+ "module": "esnext",
+ "moduleDetection": "force",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "types": ["vite/client", "vitest/globals"]
+ },
+ "include": ["src"]
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..88a659f
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "files": [],
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
+}
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 0000000..0f044fc
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["es2023"],
+ "module": "esnext",
+ "moduleDetection": "force",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..017e5f5
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,24 @@
+///
+import { fileURLToPath } from "node:url";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ },
+ },
+ server: {
+ proxy: {
+ "/api": "http://127.0.0.1:8080",
+ "/metrics": "http://127.0.0.1:8080",
+ },
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ },
+});