25 KiB
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)
- 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
/. - Location: everything under
web/(PLAN.md:234):web/package.json,web/src/,web/index.html, build outputweb/dist/(gitignored).web/dist-placeholder/stays untouched and stays the-Dweb-distdefault. - Toolchain: node >= 24 (
engines), npm with committedweb/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 stubfetch/EventSourceby hand. - 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
<title>+ 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. - 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). - API layer: one
src/lib/api.tstyped client overfetchwithcredentials: "same-origin". Types insrc/lib/types.tstranscribed 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"). - Auth flow:
/loginroute outside the app shell. On any 401 the query layer redirects to/login?redirect=<path>. Login POST withauth_required=falsein 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. - Pause control is global: a shell-header widget (pause/resume with duration
presets 60s/5m/30m/indefinite, countdown from
until,pauseddisambiguates nulluntil). Not a page. - Live log page:
EventSourceon/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). Onerror/close the browser auto-reconnects (server sendsretry: 3000); on reconnect the page re-syncs the gap viaGET /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. - Query log page: keyset pagination exactly per contract (
limit,before, follownext_beforeuntil 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. - 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.passwordis 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_enabledis read-only derived.web.password_hashis never sent. - Blocklists page shows per-source
skipped_regex_count(PLAN.md:38) and source status (state, last_success, counts, last_error). "Update now" callsPOST /api/blocklists/update, REPLACES list state from the 202 snapshot, disables the button while in flight. - 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 whendisk.state != "ok"(PLAN.md:465), plus queries_dropped/writer_failed indicators. Auto-refresh via QueryrefetchInterval30s (health 10s). - 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). - Clients page: table of all clients (
hand_editedbadge), 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. - 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).
- 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.
- Responsive: sidebar nav collapses to a top bar + drawer under
md:; tables getoverflow-x-autowrappers; the dashboard grid stacks. Desktop and mobile per PLAN.md:562 — no separate mobile pages. - 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. Onesrc/lib/format.ts, tested. - 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.
- 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
.gitignoreentries. - CI: new
frontendjob (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), uploadingweb/distas an artifact is NOT needed — instead the existingcrossjob gains: setup-node,npm ci && npm run build(prefix web), thenzig build cross -Dweb-dist=web/dist, then the existing static assert plus a size assert (< 15 MB per exe). Thetestjob stays Zig-only. - Accessibility floor: semantic elements, labeled inputs, focus-visible styles, buttons not divs. No ARIA deep-dive beyond what semantics give.
- No new pages, no dark-mode toggle bikeshed (Tailwind default palette, system
prefers-color-schemevia 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);Registerdeclared soLink tois 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<T>core (same-origin, 204→void,{error}envelope, Retry-After on 429),requestText, functions for all 55 route-method pairs, list envelopes unwrapped to arrays,liveQueriesUrlfor F4. - queryClient: staleTime 30s; no retry on 4xx except 429 (max 2, delay=retryAfter);
QueryCache+MutationCache onError →
/login?redirect=<path+search>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; NOTEblocklistsUpdateNowMutationseedsqueryKeys.blocklistSourcesfrom 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 sessionStoragenxdns_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 buildall green in web/.- All ten pages implemented per rulings; login + pause + restart banner work.
zig build cross -Dweb-dist=web/distgreen; 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).