feat(ui): React UI overhaul — plan + Milestone 1.1 foundation (WIP) - #30
Merged
Conversation
Incremental React overhaul (incident-triage first) with permalinks and a /design-sync-ready component library. Plan for PRD FR45-53 / NFR19-24; refined through two multi-agent review cycles. Written to a new path so the completed docs/plans/main.md is untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…detail views Draft glossary mapping current Jinja UI labels → standardized SRE labels covering investigation phases, workflow states, list column headers, detail section headings, evidence sub-sections, step types, and remediation stages. Derived from literal template strings; cross-checked against CRD enums. Five open decisions flagged for human review before React implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Flask serving (Task 1.1) - Add ui/frontend/ — Vite 8 + React 19 + TypeScript 6 + oxlint scaffold (npm run build, typecheck, lint, verify-build all pass clean) - Add Node build stage to ui/Dockerfile (node:24-slim, npm ci, npm run build) that copies dist/ into runtime image at /app/frontend/dist/ - Add beeper_ui/routes/react_shell.py — minimal Flask blueprint serving the SPA shell at /app/* (index.html fallback for client-side routing; hashed assets served directly); does NOT shadow existing Jinja routes (Task 1.3) - Wire react_shell_bp into beeper_ui/routes/__init__.py - Add ui/tests/test_react_shell.py — 5 pytest smoke tests asserting /app/ returns index.html, /app/<subpath> returns index.html, hashed JS/CSS assets return 200 with correct content-types, and existing Jinja routes are intact - Add ui/frontend/scripts/verify-build.mjs — standalone Node script asserting dist/index.html + hashed JS + CSS files exist and index.html references the JS entry (run via npm run verify-build) - Gitignore ui/frontend/dist/ (build output; rebuilt by Docker node stage) All 5 smoke tests pass. npm run build/typecheck/lint clean. Docker node stage wired and inspected; serving half proven against local dist/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nalysis Failed; OD-2/3/5 defaults) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add @tailwindcss/vite plugin; wire into vite.config.ts (proxy-safe)
- Add src/theme/tokens.css as single source of truth for all design
tokens: surfaces (#0f0f1a/#1a1a2e/#252540), primary (#6366f1/hover),
status colors, text hierarchy, spacing scale (4px base), layout
dimensions (sidebar 64px/256px, topbar 48px), breakpoints (768/1200px),
font stacks (system-ui/ui-monospace), motion tokens (200ms sidebar,
5s highlight, 2s emphasis), step-type border colors
- Add eslint-plugin-tailwindcss with no-arbitrary-value (error) and
no-custom-classname (warn); enforce via lint:tw script
- Add vitest + React Testing Library; 41 tests prove both ACs:
[T1] token-resolution.test.tsx — CSS variable exact-hex assertions +
RTL class-presence tests on TokenSwatch component
[T2] lint-enforcement.test.ts — ESLint exit-code tests against
deliberate violating fixture (exit 1, mentions no-arbitrary-value)
and clean fixture (exit 0)
- Add vitest.config.ts (separate from vite.config.ts); tsconfig.test.json
for test-specific type resolution; test files excluded from prod build
- motion-reduce coverage: TokenSwatch motion group proves the
motion-reduce:transition-none pattern; global @media override in
tokens.css as belt-and-suspenders (NFR22/FR51)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add three new endpoints to the investigations API blueprint:
- GET /api/v1/investigations/: JSON list with status/service/severity filters
- GET /api/v1/investigations/{id}: expanded detail with full metadata + ordered steps
- GET /api/v1/investigations/{id}/events: SSE stream emitting step events + terminal
Implement _generate_json_sse_events generator and configurable poll interval
(default 1s, meets NFR21 ≤2s latency target; configurable via JSON_SSE_POLL_INTERVAL
Flask config key). Q5 resolved: lower the JSON stream interval; HTML SSE unchanged.
Add 29 comprehensive tests covering endpoint shape, metadata/step ordering,
event stream ordering, and poll interval configurability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement deterministic request routing that allows a configured registry of React-owned path prefixes to take precedence over Jinja blueprints, regardless of blueprint registration order. Add REACT_OWNED_PREFIXES config key (empty tuple by default) to specify which URL paths the React SPA shell should handle. A before_request hook checks incoming paths against this registry and short-circuits to the shell if matched, preventing order-dependent races with Jinja routes. - /api/* and /app/* are permanently excluded from the registry match, ensuring the JSON API and shell assets are never shadowed. - Registry starts empty — real view migrations will populate this list incrementally (Milestone 1.2+) as part of the React rollout plan. - Prefix matching mirrors Flask's url_prefix semantics: a path matches if it equals the prefix exactly or starts with <prefix>/. Mechanism-only change; no views migrated yet. Add 15 pytest tests proving: - a registered prefix serves the SPA shell while an unmigrated route still renders its Jinja page - /investigations resolves to React when registered, Jinja when not (deterministic precedence toggle) - /api/* and /app/* remain unaffected even with a broad registered prefix - prefix-matching predicate correctly rejects sibling paths Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
….3 done Vite emits base64url content hashes (e.g. index-CipiD-gB.js) that the test_react_shell asset regex rejected, causing build-dependent failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stand up src/lib/ as the reusable component library — Radix primitives + Tailwind, skinned to the Task 1.2 dark-first tokens (tokens.css consumed, never redeclared) — and define it as the extraction boundary Milestone 1.2 views will build against. Scaffolded skeleton components (typed props + token-only styling + one Storybook story each), matching the Task 4.4 design-sync inventory: InvestigationCard (active/completed/failed), StatusBadge (job-phase/ workflow-state/pipeline-health variants per the terminology glossary — job-phase Failed -> "Analysis Failed", workflow-state Failed -> "Failed"), InvestigationStep, SummaryHeader, RelatedKbPanel (built on @radix-ui/react-collapsible). Library builds independently via `npm run build:lib` (vite.lib.config.ts, kept separate from vite.config.ts per Task 1.5 scope) to dist-lib/ — an ESM bundle + full .d.ts tree, react/react-dom externalized. Storybook (storybook + @storybook/react-vite) added with its own .storybook/ config importing tokens.css directly, no changes to the app's Vite config. [T] src/lib/test/dist-import.test.ts dynamically imports the built dist-lib/index.js and asserts every named export (5 components + cn) is present and typed — proving external-consumer importability, not just that Storybook builds. [H] DESIGN_SYNC.md documents the build command, dist-lib layout, and full export/variant inventory for the parent session's trial `/design-sync` run (resolves Q3) — PENDING, cannot be run from this worktree. Also: un-ignore src/lib/ in ui/frontend/.gitignore (the root repo's Python-oriented `lib/` rule was shadowing it); scope tsconfig.app.json / tsconfig.test.json so stories + the dist-import test live in the test project, not the shipped app build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…red to 4.4) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ests Repo-wide 'ruff check .' (CI 'Python UI' lint step) flagged unused imports (MagicMock, Investigation) and import ordering that per-file agent checks missed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stry toggle test The toggle-precedence test issued the React-owned request unconditionally, so serve_react_shell() read a missing dist/index.html and raised FileNotFoundError in the Python-only CI job (no frontend build). Move the request inside the _dist_available() guard, matching the other shell-serving tests; the Jinja half still always runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Playwright (@playwright/test, playwright.config.ts) to test the built app via `vite preview` (matching BFF static serving), with a minimal e2e spec (e2e/app-shell.spec.ts) that proves React SPA mounts in a real browser. Wire a new "Frontend UI" CI job into .github/workflows/ci.yml (Node 24, npm ci, typecheck, build:lib, vitest, playwright install, build, e2e), with artifact upload of the HTML report. Exclude e2e/** from Vitest's config to avoid double-collection. Add Playwright output dirs (test-results, playwright-report, blob-report) to .gitignore. Document testing workflow in README.md (build before e2e; first-time setup needs `npx playwright install --with-deps chromium`). Task 1.7 AC [T] satisfied: Playwright e2e runs green in CI, proving the production bundle mounts in a real browser, not just jsdom. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add server.proxy in ui/frontend/vite.config.ts forwarding /api/* and the
JSON event endpoint (/api/v1/investigations/{id}/events) to the Flask BFF
(BFF_ORIGIN, default http://localhost:5000). The events route gets its own
scoped proxy entry that keeps http-proxy's default per-chunk pipe-through
(no selfHandleResponse) and re-asserts the no-buffering headers, so the SSE
stream flows through in real time instead of being batched at connection
close — verified manually against a live vite dev + stub SSE server (chunks
arrive as written, ~500ms apart) and against an earlier double-pipe bug
(a redundant manual .pipe() next to http-proxy's automatic one).
Document the two-terminal dev workflow (poetry run flask run + npm run dev)
in ui/README.md, mirroring the existing make tailwind-watch + flask run
pattern.
[T]: resolved vite.config.ts proxy config (target/changeOrigin/streaming
settings) asserted via Vite's resolveConfig API in
ui/frontend/src/test/dev-proxy.test.ts (5/5); 51/51 frontend tests green.
Production build (vite build) unaffected — server.proxy only applies to
vite dev.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve InvestigationDetailPage import conflict: keep both fetchInvestigationDetail (2.6b) and deriveProblemState (2.4).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give each rendered InvestigationStep a stable id="step-<order>" and add useStepAnchorScroll (src/lib/hooks/) so a cold /investigations/<id>#step-<order> load scrolls the anchored step into view once it renders, retrying as the async one-shot fetch's steps arrive and respecting prefers-reduced-motion. The hook only scrolls (never focuses), so it never competes with Task 2.1's useRouteFocusManagement header focus on the same cold load — both are proven together in a new e2e suite (e2e/detail-permalink.spec.ts) alongside the FR44 sidebar auto-collapse. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lifts the investigation-list status-group filter (active/resolved/failed, FR22) out of local component state and into the `?status=` URL query param via React Router's useSearchParams (FR53). Selecting a filter updates the URL; cold-loading a `?status=resolved`/`?status=failed` URL directly (no prior in-app state) reproduces the identical filtered list and tab selection. An absent or unrecognized `?status=` value falls back to the "active" default instead of crashing. The underlying JSON list fetch stays unfiltered (no `status` query param) regardless of the selected group, unchanged from pre-Task-3.1 behavior: `list_investigations(status=…)` only accepts a single job-phase status, `active` spans two (investigating + awaiting_confirmation), and the group-count badges need full cross-group data to stay accurate no matter which group is selected — so status-group filtering/ordering continues to be applied client-side post-fetch (R6/D6: no operator change). A new `deriveListFetchStatus` guards a specific pitfall: `failed` is spelled identically in both the status-group and raw API `status` vocabularies, so a naive pass-through would silently narrow the fetch the moment that tab is selected, breaking the sibling groups' counts. `status-group.ts` gains an `isStatusGroup` type guard for parsing the URL param back into a `StatusGroup`; its filter/order functions remain router-agnostic per Task 2.2's design. Tests: RTL coverage for URL updates on selection, cold-load reproduction (valid/invalid/absent param), fetch-call-args assertions, and cross-group count accuracy (`status-group.test.ts`, `InvestigationListPage.test.tsx`); a focused Playwright cold-load permalink test for the list (`e2e/investigation-list.spec.ts`) — the full cross-view permalink suite is Task 3.4. CI order verified clean: npm ci → typecheck → build:lib → 295 vitest → build → 19 e2e. No package.json changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's stable toolchain rolled forward and clippy now enforces question_mark:
the `match prev { None => return None, Some(pv) => ... }` rate-of-change guard
is rewritten as `let pv = prev?;`. Behavior-preserving (13 detection::metrics
tests pass); unblocks the Rust Operator check on this frontend-only PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add e2e/permalink-chrome-boundary.spec.ts proving sidebar expand/collapse and scroll offset are local chrome state that does NOT ride along in the permalink URL, while the status-group filter (content state) does — using browser.newContext() for the "fresh" side of the comparison so sessionStorage carryover within a tab can't be mistaken for URL-encoded state. Test-only; no source gap found (useSidebarState/useScrollRestoration already behave correctly). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds e2e/permalink-integrity.spec.ts proving the session's headline requirement holistically rather than per-mechanism: a copied triage URL, opened cold, reproduces the identical view. - Filtered-list permalink (?status=resolved): cold-loads to the identical filtered/ordered rows AND restores the saved scroll offset (sessionStorage-seeded via addInitScript, matching how useScrollRestoration persists it) — the scroll half of "identical view" NFR24 calls out. - Completed-investigation detail permalink anchored to a step (#step-<order>): cold-loads to the identical detail view (service, severity, Completed badge) and scrolls the anchored step into view. This specifically closes the gap left by 3.2's own suite (detail-permalink.spec.ts), which only ever exercised the step anchor against status: 'investigating' investigations, never a Completed one. No implementation gap found — 3.1/3.2's existing code already handles both scenarios correctly; this is a test-only addition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…te (4/4) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mation Milestone 1.4, Task 4.2: finalize/polish the src/lib component library for the Task 4.4 /design-sync pass. - Add InvestigationStep's missing `Deploy` step-type story (5/6 -> 6/6 step types covered) and InvestigationCard's `WithComponentAndProblemState` story, covering the `component`/`problemState` subtitle slots the variant-only stories didn't exercise. - Add src/lib/test/story-coverage.test.ts: parses the src/lib/index.ts barrel for every exported component and asserts a `<Name>.stories.tsx` exists, wires its Storybook Meta to the real component, and exports >=1 story, plus a defense-in-depth pass over every directory under src/lib/components/. Proves the "every first-increment component has a Storybook story" AC automatically instead of by inspection. - Expand dist-import.test.ts's EXPECTED_NAMED_EXPORTS from the original Task 1.4 subset of 5 components to the full current barrel (15 components + 7 hooks + cn), so the "library builds to dist-lib" AC proves the whole current library is importable from the compiled bundle, not a historical slice of it. - Confirm token-only styling: no-arbitrary-value lint reports 0 errors across src/lib (FR51); the two known gaps from the 1.4 plan bullet (3px step border, 50vh KB-panel max-height) were already resolved token-safely (border-l-2, max-h-96). - Update DESIGN_SYNC.md (the /design-sync input-contract doc) from its Task 1.4 skeleton snapshot (5 components) to the full current 15- component/7-hook inventory, dist-lib tree, and Storybook story map, plus a token-enforcement status section — so Task 4.4 has accurate input. No public API changes. Full CI-order gate re-verified clean: 339 vitest (307 baseline + 32 new story-coverage) / 28 e2e, build:lib, build, build-storybook all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend Task 1.8 DRAFT glossary into FULL FR52 deliverable with new frontend-local linting to enforce terminology compliance: - Add §14 (lint coverage decisions) and §15 (per-view visual-density audit) reconciling glossary against shipped component reality (step-type labels) - Implement legacy-label lint: rule engine + CLI scanner for terminology drift across routes, components, investigations - Vitest suite with deliberately-violating fixture for lint validation - Wire as `npm run lint:terms` (additive, not part of CI safety gate) Per-view density audit documents specific elements to remove/de-emphasize; deferred implementation to Task 4.2. Glossary and audit content pending user sign-off. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + legacy-label lint
D1: suppress the unconditional 'Impact: not yet correlated' placeholder — the impact line now renders only when the backend supplies correlated services (FR48 forward-compatible; supersedes the 2.5 placeholder AC). D2: summary-type steps no longer render a redundant 'Summary' type badge, per the user-approved 1.8 glossary decision. L1 deferred by user choice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…config Vite's default base '/' made the built index.html reference /assets/*, which 404s when the Flask BFF serves the bundle at /app/* (vite preview masked it via root SPA fallback — found standing up the Task 4.3 glance-test demo). base '/app/' matches react_shell_bp and the router basename. Full gate green: 345 vitest + 28 e2e. Also: launch.json 'beeper-demo' config (demo_ui.py :5050, mocked services) and Task 4.3 marked in progress. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… incidents, ≤5s) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ase 1 complete Full /design-sync of the 15-component library to project 'Beeper Design System' (616ba082). All 49 stories verified pixel-true against the repo's own Storybook render. Repo changes: package.json 'types' -> dist-lib (export discovery), DarkCanvas preview provider (dark-first canvas, token var()s), sync config + notes + conventions header, sync-artifact gitignores. Marks 4.4 done, lifts 1.4 to done, resolves Q3 — Milestone 1.4 and Phase 1 COMPLETE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ethompsy
marked this pull request as ready for review
August 4, 2026 22:58
…sts, CI playwright report Adversarial pre-merge review of the branch found: (1) no .dockerignore, so the Docker node stage's COPY clobbered npm-ci's linux node_modules with host macOS artifacts (~245MB) — added ui/.dockerignore; (2) the react-shell Flask tests permanently skipped in CI (no frontend dist in the Python UI job) — the job now builds the frontend first, so the Flask-serves-React contract is exercised (20 tests run, 0 skipped); (3) CI's playwright-report artifact was always empty (html reporter disabled on CI) — html reporter now always on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Establishes the SRE-centric React UI overhaul for Beeper: the implementation plan plus the Milestone 1.1 foundation (7 of 8 tasks). This is the incremental React migration (incident-triage first) that also unblocks a future Claude Design (
/design-sync) pass. Work in progress — draft.Traces to the "SRE-Centric React UI Overhaul" section of
docs/reqs/main.md(FR45–53, NFR19–24). The completed pipeline/UI plan (docs/plans/main.md) is untouched; this is a separatedocs/plans/react-ui.mdworkstream.What's included
Plan —
docs/plans/react-ui.md, refined through two multi-agent review cycles.Milestone 1.1 — Foundation (done):
ui/frontend/; Dockerfilenode-builderstage; Flask serves the SPA at/app/*.motion-reduce:./api/*+/app/*excluded) — enables coexistence.InvestigationCard,StatusBadge,InvestigationStep,SummaryHeader,RelatedKbPanel+ Storybook + adist-libbundle.list/detail/events) reusing the operator-poll loop; JSON stream polls at 1s (meets NFR21).Remaining in 1.1 (follow-up): 1.5 (Vite dev-proxy) and 1.7 (Playwright + CI).
Why / reviewer notes
investigations/<id>/streampolls the operator and emits Jinja HTML for HTMX, so React needed a new JSON stream (D10). The UI is also not unauthenticated —middleware/permissions.py(two-tier RBAC) andwebsocket/(Flask-SocketIO) exist;docs/specs/architecture.md's "no auth / SSE only" is inaccurate (Q7, tracked).StatusBadgeencodes a glossary decision: job-phase failure → "Analysis Failed", workflow failure → "Failed".[H]trial/design-syncis deferred to Task 4.4 (the plan's formal NFR23 gate) — nothing has been uploaded to claude.ai.ui/frontend/DESIGN_SYNC.mddocuments the bundle for that run.Testing
distimport).Built via parallel per-task git worktrees; each task merged after its
[T]criteria passed.🤖 Generated with Claude Code