Files
nxdns/specs/milestone-9.md
T
mokhtar 5b3d1cd65c
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
docs: unwrap hand-wrapped prose repo-wide
2026-08-15 16:27:36 +02:00

166 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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<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<n> 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).