Files
nxdns/specs/milestone-29.md
T
mokhtar fa323c7ed4
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
milestone 29: activity — history, live and policy simulation on one surface
query log, live and lookup merge into /activity. history filters live
in the url, so a pasted link or back/forward reproduces the exact
view; the result column separates servfail and nxdomain from success
in the list. live is follow-by-default with freeze, and a streamed
row opens its in-memory provenance detail — no correlation invented
for rows sqlite has not written. lookup survives as the current
policy simulation under /activity/test. investigation links carry
absolute bounds, and the diagnostics page now honors since/until
instead of ignoring them. the old routes are gone without aliases.
2026-08-22 10:52:56 +02:00

12 KiB

Milestone 29: Activity consolidation

Redesign step 3 of specs/ui-redesign.md ("Activity", "Time scoping", "Deletions and their cost", build-sequence step 3). Query Log, Live and Lookup merge into one Activity surface; the three old routes and their code are removed in the same change. Admin-only — no Zig, wire or openapi changes; if a session believes it needs one, that is a spec bug to report, not code to write. Codex design review folded in (thread 01a02857); its corrections are marked where they changed the shape.

Sessions

S1: route-independent primitives only — it must not touch routes.tsx, AppShell.tsx, or move any route-bound page (Codex: the original move-then-delete split could not keep the tree shippable between sessions). S2: one atomic landable session — routes, page assembly, moves, deletion, retargeting, test migration, smoke. Sequential.


Session S1: primitives (no route changes)

S1.1 Summary projection and cells

  • QuerySummary (querySummary.ts:13) gains rcode and route_kind only. Result derives from the existing blocked projection (which already folds live policy.action — Codex: the summary has no policy_action and does not need one), else the rcode.
  • New admin/src/features/activity/cells.tsx: the seven-column set. The Domain cell takes a route-neutral link-renderer prop (Codex: S1 cannot reference the not-yet-existing typed route without failing typecheck); S2 supplies the typed Link to /activity/queries/$id. — Time, Domain, Client, Type, Result, Route, Duration. Table-only compact labels, defined and tested exactly here (the detail page keeps provenanceCopy's long forms): Result → Blocked | NOERROR | SERVFAIL | … (bare rcode name, no numeric suffix); Route → Blocked | Local | Forward zone | Upstream | Cache | Rejected. Non-NOERROR result carries a non-color signal (weight/icon), same treatment as Blocked. Duration formats response_time_us, em dash when null.
  • Tests: fixtures for blocked, allowed-NOERROR (explicitly blocked=false — Codex: the allowed case must be pinned, not implied), SERVFAIL, cache hit; each cell's exact text, including an unassigned extended rcode preserving provenanceCopy's RCODE <n> fallback shape (compact form without the parenthesized number).

S1.2 Shared provenance-detail renderer (Codex critical: streamed live rows must keep a detail surface)

Extract the ordered-explanation body of QueryDetailPage (request / group / policy / rewrites / route / response sections, historical-vs-current separation, the honesty wordings) into admin/src/features/activity/ProvenanceDetail.tsx taking a Provenance plus optional persisted id. QueryDetailPage becomes a thin route wrapper around it (page stays at its current route in S1 — only the body moves to a route-independent component). The renderer takes related-action links as props so S1 stays route-agnostic.

S1.3 Form/URL plumbing primitives

  • Select (ui/Select.tsx:23) gains isDisabled, passed through to React Aria (Codex: the filter row cannot otherwise be disabled).
  • Datetime conversion becomes bidirectional and second-exact: datetimeLocalToUnix gets its inverse (unixToDatetimeLocal, local time), inputs use step={1}. DST makes local text lossy (Codex: a fall-back fold maps two instants to one string; a spring-forward gap silently normalizes): the form keeps the original unix value plus a per-field dirty flag, reuses the original unless the operator edited that field, and rejects an edited value that does not format back identically after parsing. Tests: non-zero-second round trips, a DST-fold instant, a nonexistent spring-forward time.
  • Search-param validators as pure functions in activity/search.ts: since/until accepted only via Number.isSafeInteger; blocked only when typeof === "boolean"; mode in the two-value union defaulting "history"; domain/client trimmed, with the empty string normalized to undefined (Codex: domain= must not persist as applied state the server treats as no filter). Tests: fractions, Infinity, overflow, quoted booleans, false, empty and whitespace strings.

S1.4 Acceptance (S1)

  • Typecheck, vitest, oxlint, format clean; existing pages still work untouched (only additive files plus the QueryDetailPage body extraction and Select prop).

Session S2: the Activity surface, atomically

S2.1 Routes (routes.tsx) — created and deleted in one change

  • /activity → ActivityPage, validateSearch from S1's validators. loaderDeps returns an explicit object of {mode, since, until, domain, client, blocked} — never the whole search (Codex: unknown keys create spurious matches without search.strict); the history loader builds QueriesFilter field-by-field and only calls ensureInfiniteQueryData in history mode.
  • /activity/queries/$id → the detail wrapper (loader unchanged, incl. the deliberate 404 swallow). The route's search carries the originating Activity search (validated by the same functions), so the back link restores the exact investigation view (Codex: a bare /activity back link discards context and violates absolute time scoping).
  • /activity/test → policy simulation, search domain, ?domain= auto-run preserved.
  • /queries, /queries/$id, /live, /lookup deleted from the route tree in the same edit. No aliases, no redirects; unknown paths fall to the router's existing not-found handling (verify sane, build nothing).

S2.2 ActivityPage

  • Mode switch is URL-controlled (the uncontrolled Tabs at ui/Tabs.tsx:76 does not fit — a controlled switch or controlled-Tabs variant), updating via the functional form search: prev => ({...prev, mode}) so filters survive (Codex: object-replacement navigate loses them). The Live subtree (LiveActivity) mounts only in live mode so no SSE connection lingers in history. Tests: switching to history closes the EventSource; switching back creates exactly one fresh source.
  • History mode: URL is the applied state. The form draft resets whenever the applied search changes (back/forward, pasted URL — Codex: the current seed-once pattern fails back/forward; test both, with non-zero seconds in the bounds). Apply = navigate with the explicit search object; keyset/infinite mechanics (next_before, isPlaceholderData guard, hasMore derivation) carry over; CoverageNotice stays.
  • Live mode: follow-by-default, Freeze/Resume ephemeral (never URL). The live stack moves as-is: useLiveQueries (freeze snapshot, gap re-sync, 401 probe, cap threshold, stale-source guards), ringBuffer (LiveRow union, occurrence-counting mergeGap, rationale comment verbatim), fakeEventSource injection, status pill, aria-pressed Freeze, missed-recovered note, resync-failed alert, capped alert + Retry, blocked-row tint, footnote count.
  • Live detail: a streamed row (id null) opens ProvenanceDetail in-place from its in-memory event — no invented correlation id, no persisted fetch (ui-redesign.md:177). Recovered rows link to /activity/queries/$id. Retention contract (Codex): selection stores the selected LiveRow snapshot itself; the open detail survives ring eviction, gap merges, and Freeze/Resume, closing only on explicit close or mode unmount. Tests pin: opening a streamed row's detail, keyboard access, survival across capacity eviction (501+ events) and across Freeze/Resume.
  • Filter row in live mode: disabled via S1's isDisabled (visible, value-preserving), including native inputs and Apply/Clear; keyboard/tab-order test proves nothing in the row is tabbable while disabled.
  • Detail-only facts, stated per what the wire actually carries (Codex: no source URL or upstream error text exists on QueryDetail): matched pattern, historical source label, selected upstream, rcode. Underlying failure text lives in Diagnostics — the detail page's related actions gain the accepted absolute-window Diagnostics link: /diagnostics?since=<ts-300>&until=<ts+300> (the redesign's five-minute window around the query, ui-redesign.md:173).
  • Related domain/client links carry absolute bounds: /activity?mode=history&domain=…&since=…&until=… with since = origin.since ?? ts - 300 and until = origin.until ?? ts + 300 per bound independently (Codex: the fallback is product contract, not implementation choice). Tests cover zero-, one-, and two-bound origins.
  • Diagnostics bounds must actually apply (Codex: /diagnostics neither validates nor uses since/until at HEAD, so the ±300s link would render an unbounded page): the diagnostics route gains safe-integer since/until search validation and explicit loaderDeps, DiagnosticsPage applies them to both the active and resolved queries, and the page shows a visible range indication when bounded. Tests assert the outgoing /api/diagnostics requests carry the bounds.

S2.3 Policy simulation

LookupPage moves to activity/ with the "Current policy simulation" framing (forward-tense property preserved); verdict rendering, 503/429 handling, group select, same-pair refetch carry over. Reached from an Activity action, not primary nav.

S2.4 Navigation, deletion, migration

  • NAV_ITEMS: Query Log/Live/Lookup out, {to: "/activity", label: "Activity"} in their position.
  • Old page files, their cells/stylex exports, and dead helpers deleted. One home per module under activity/; no re-export shims.
  • Test migration with the no-shrink rule: every behavior pinned by the old tests is re-pinned at the new home or listed in the report as intentionally dead with its page. Files: QueryLogPage.test, LiveLogPage.test, useLiveQueries.test, ringBuffer.test, LookupPage.test, QueryDetailPage.test, AppShell.test, LoginPage safeRedirect samples (cosmetic).
  • Sweep: no import, string or test references /queries, /live, /lookup as routes (API paths /api/queries* stay).
  • CHANGELOG.md is S2-owned (Codex): retarget the still-Unreleased m28 entry's /queries/{id} mention to the new route, add the removal notice (bookmarks break) and the Activity surface + Result column.

S2.5 Smoke + screenshots

Rebuild bundle (npm run build, zig build -Dadmin-dist=admin/dist), run the real binary with the smoke28 scratchpad config: URL-driven history filters (paste a full URL, screenshot the reproduced view), live streaming with freeze and an open streamed-row detail, the SERVFAIL row distinguishable in the list, detail via recovered/history row with the context-preserving back link, policy simulation, five-minute Diagnostics link landing on the bounded window, mobile-width nav drawer.

S2.6 Acceptance (S2)

  • Typecheck, vitest, oxlint, format clean; zig build -Dadmin-dist=admin/dist succeeds; zig build test untouched-green.
  • The S2.2 test list green, including EventSource lifecycle, back/forward, disabled-row tab order, streamed-row detail.
  • Router-level loader regression (Codex): /activity?mode=live — including with retained filters and unknown search keys — makes no /api/queries request; history mode forwards exactly the six normalized filter fields and nothing else.
  • Screenshots per S2.5.

File ownership

Sequential; S2 starts only after S1's gates are green. S1: additive files under activity/, querySummary.ts, ui/Select.tsx, the QueryDetailPage body extraction, their tests. S2: everything else listed, admin/src/* (including the diagnostics route/page bounds work) plus CHANGELOG.md.

Anti-requirements

  • No server/API/openapi changes; no golden regeneration.
  • No route aliases, redirects, or bookmark shims; no custom 404.
  • No filter push-down into the SSE stream.
  • No Overview changes (step 4), no configuration-page changes (step 5).
  • No new dependencies; no virtualization.

Acceptance (milestone complete)

  • All admin gates clean; zig suites untouched-green; bundled build succeeds; screenshots per S2.5.
  • CHANGELOG updated per S2.4.