Production image for the frontend dist.
Stage 1 (build): node:22-alpine, `npm ci --ignore-scripts` from the
committed lockfile, `npm run build`. Output lands in /app/dist.
Stage 2 (runtime): docker.io/nginxinc/nginx-unprivileged:alpine.
- Upstream-maintained variant that runs as the nginx user (uid 101)
out of the box. /var/cache/nginx and /var/run/nginx are pre-owned,
no chown gymnastics needed in our layer. Vanilla nginx:alpine fails
at startup as non-root because client_temp mkdir is denied.
- Listens on 8080 (non-privileged port, matches the unprivileged
variant convention).
- nginx.conf serves /usr/share/nginx/html with SPA `try_files`
fallback for client-side routing, long-cache headers on
/assets/ (Vite hashed bundles), a plaintext /healthz endpoint
for Caddy / Prometheus blackbox, and server_tokens off.
.dockerignore excludes node_modules, dist, .vite, coverage,
playwright-report, .env*, .git, editor dirs. Keeps .env.example.
Smoke local validated with `podman build -t mimic-frontend:smoke .`
and `podman run -p 127.0.0.1:18080:8080`:
/healthz -> 200 "ok"
/ -> 200 index.html (508 B)
/spa/x -> 200 (SPA fallback)
/assets -> Cache-Control: max-age=31536000, public, immutable
M1 — Single SessionProvider via nested router.
The previous router had two route entries with `path: '/'`
(Navigate, AppShell) plus a separate `/login` entry, each wrapped in
its own RootLayout. That instantiated SessionProvider three times,
forking state the moment session writes diverged across siblings.
Replaced by one Root route with SessionProvider + <Outlet />, and
index/login/AppShell-children nested underneath. RootLayout (the
per-tree wrapper) is now obsolete and deleted; the new Root component
lives in src/routing/Root.tsx (addresses NIT N4 as a side effect).
M2 — Typo: "pollign" → "polling" in LiveCockpitPage masthead.
M3 — Replace asymmetric `?? 'rt_operator'` / `?? 'soc_analyst'`
fallbacks in LiveCockpitPage with an explicit `if (!user) return null;`
guard placed after all hooks (rules-of-hooks). AppShell already
redirects unauthenticated visitors to /login, so the guard documents
the invariant rather than introducing one.
NITs N1-N3, N5-N7 recorded in tasks/todo.md as sprint 1+ follow-ups.
- Role enum (rt_operator, rt_lead, soc_analyst) aligned with spec §3 / F11.
Frontend predicates (isRT, isLead, isSOC) drive layout only — backend
remains the source of truth for permissions (D-008).
- SessionContext split into Provider (TSX) and hook (useSession) to satisfy
react-refresh/only-export-components.
- AppShell composes StatusRail (link health, active run, UTC clock, build) +
Sidebar (role-conditional nav with keyboard shortcut hints) + Outlet.
Unauthenticated visitors redirect to /login.
- StatusRail uses pulsing status-dot pattern and label-system micro-typo
(uppercase 10px, 0.08em tracking) to evoke an instrument-panel header.
- Router (createBrowserRouter): /login outside the shell, all app routes
nested inside the shell. RootLayout extracted to its own file for
fast-refresh compliance.
Routes (sprint 0, flat):
/login LoginPage
/engagements EngagementsPage
/library TtpLibraryPage (RT only — gated client-side, will
be re-enforced by backend RBAC)
/scenarios ScenarioComposerPage (RT only)
/runs LiveCockpitPage
/reports ReportPage
/audit AuditPage (lead RT only)
Sub-routes under /engagements/:eid land in sprint 1+ when real scoping
arrives.