Files
nxdns/specs/milestone-9.md

410 lines
25 KiB
Markdown
Raw Permalink 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).