Files
nxdns/specs/milestone-9.md
T

25 KiB
Raw Permalink Blame History

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 <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.
  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=<path>. 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 ciprettier --check → oxlint → tsc --noEmitvitest runvite 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 + /metricshttp://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<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, liveQueriesUrl for 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; 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 = queriesblockedcached 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).