milestone 9: react spa admin ui, frontend ci and embedded dist
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
# Milestone 9: React SPA admin UI (PLAN §14, §3.14)
|
||||
|
||||
Goal: the complete admin UI — ten pages per PLAN.md:560, login, pause control, restart
|
||||
banner — built with Vite + React + TypeScript + Tailwind + TanStack Router/Query,
|
||||
embedded in the binary via the existing `-Dweb-dist` pipeline, with a frontend CI job.
|
||||
|
||||
Ground truth: PLAN.md:118-120 (auth UX), :138-140 (stack + embedding), :145/:579 (CI),
|
||||
:454-456 (live view), :531 (restart banner), :560-562 (pages + requirements), :639 (size
|
||||
budget), :641 (upstream health in UI); specs/milestone-8.md W8/W9 As-built (asset
|
||||
pipeline, SPA fallback, --web-dev); src/web/openapi.yaml (the API contract — authorative
|
||||
for every body shape; do NOT guess fields, read it).
|
||||
|
||||
## Rulings (binding)
|
||||
|
||||
1. **Stack per PLAN.md:138, not house style**: Vite + React 19 + TypeScript + Tailwind
|
||||
v4 (CSS-first config, no tailwind.config.js) + TanStack Router + TanStack Query. The
|
||||
reference repos use StyleX and no TanStack; PLAN is the source of truth here. SPA, no
|
||||
SSR, base `/`.
|
||||
2. **Location**: everything under `web/` (PLAN.md:234): `web/package.json`, `web/src/`,
|
||||
`web/index.html`, build output `web/dist/` (gitignored). `web/dist-placeholder/`
|
||||
stays untouched and stays the `-Dweb-dist` default.
|
||||
3. **Toolchain**: node >= 24 (`engines`), npm with committed `web/package-lock.json`.
|
||||
Dependencies exact-pinned (house style). Checks: `tsc --noEmit`, Prettier (house
|
||||
config: tabs, tabWidth 4, printWidth 120, semi, double quotes, trailing commas all),
|
||||
oxlint (zero-config), vitest + @testing-library/react + jsdom for tests. No ESLint,
|
||||
no Biome, no msw — tests stub `fetch`/`EventSource` by hand.
|
||||
4. **No charting dependency.** The dashboard chart is a hand-rolled SVG component
|
||||
(stacked bars for queries/blocked/cached over the fixed zero-filled buckets, axis
|
||||
labels, hover tooltip via native `<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 = 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<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).
|
||||
Reference in New Issue
Block a user