Skip to content

feat(ui): React UI overhaul — plan + Milestone 1.1 foundation (WIP) - #30

Merged
ethompsy merged 81 commits into
mainfrom
feature/react-ui-impl
Aug 4, 2026
Merged

feat(ui): React UI overhaul — plan + Milestone 1.1 foundation (WIP)#30
ethompsy merged 81 commits into
mainfrom
feature/react-ui-impl

Conversation

@ethompsy

@ethompsy ethompsy commented Jul 4, 2026

Copy link
Copy Markdown
Owner

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 separate docs/plans/react-ui.md workstream.

What's included

Plandocs/plans/react-ui.md, refined through two multi-agent review cycles.

Milestone 1.1 — Foundation (done):

  • 1.1 Vite 8 + React 19 + TS scaffold at ui/frontend/; Dockerfile node-builder stage; Flask serves the SPA at /app/*.
  • 1.2 Tailwind v4 dark-first tokens as the single source of truth + lint that fails on hardcoded values / missing motion-reduce:.
  • 1.3 Config-driven React-owned path-prefix dispatch (deterministic precedence over Jinja; /api/* + /app/* excluded) — enables coexistence.
  • 1.4 shadcn-style component library (Radix + Tailwind on the tokens) — InvestigationCard, StatusBadge, InvestigationStep, SummaryHeader, RelatedKbPanel + Storybook + a dist-lib bundle.
  • 1.6 BFF JSON API for React (list / detail / events) reusing the operator-poll loop; JSON stream polls at 1s (meets NFR21).
  • 1.8 FR52 terminology glossary (user-approved).

Remaining in 1.1 (follow-up): 1.5 (Vite dev-proxy) and 1.7 (Playwright + CI).

Why / reviewer notes

  • Baseline corrections found during planning (verified against code): the operator has no native SSE — the existing investigations/<id>/stream polls 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) and websocket/ (Flask-SocketIO) exist; docs/specs/architecture.md's "no auth / SSE only" is inaccurate (Q7, tracked).
  • StatusBadge encodes a glossary decision: job-phase failure → "Analysis Failed", workflow failure → "Failed".
  • Task 1.4's [H] trial /design-sync is deferred to Task 4.4 (the plan's formal NFR23 gate) — nothing has been uploaded to claude.ai. ui/frontend/DESIGN_SYNC.md documents the bundle for that run.
  • Incremental by design: React and Jinja coexist; no route is migrated yet (the dispatch registry ships empty).

Testing

  • Frontend: Vitest/RTL — 46/46 (tokens, lint enforcement, dist import).
  • Backend: full pytest suite green (2362+ passing), including 29 new JSON-API tests, 15 route-registry tests, and 5 SPA-serving tests. A latent Vite-hash regex bug in the 1.1 tests was fixed.

Built via parallel per-task git worktrees; each task merged after its [T] criteria passed.

🤖 Generated with Claude Code

ethompsy and others added 30 commits July 1, 2026 10:31
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>
ethompsy and others added 26 commits July 6, 2026 17:11
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>
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
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>
@ethompsy
ethompsy merged commit 0790386 into main Aug 4, 2026
6 checks passed
@ethompsy
ethompsy deleted the feature/react-ui-impl branch August 4, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant