Skip to content

Architecture

Components

┌─────────────────────────────────────────────────────────────────────────────────── ────┐
│ Browser                                                                               │
│ React SPA (Vite build, served by FastAPI as static files)                             │
│ - Auth: JWT in localStorage                                                           │
│ - Pages: Overview / Services / Models / Pipelines / Approvals / Config / Audit / Docs │
└──────────────┬────────────────────────────────────────────────────────────────────────┘
               │ HTTPS (operator's reverse proxy) → HTTP on 8099
┌────────────────────────────────────────────────────────────────────┐
│ FastAPI backend  (platform/services/dashboard/backend/)            │
│ ┌──────────────────────────────────────────────────────────────┐   │
│ │ routers/                                                     │   │
│ │   auth.py    POST /login (open)  /me  /logout                │   │
│ │   health.py  GET /health (open)                              │   │
│ │   config.py  GET (viewer, masked) PUT (admin, encrypts)      │   │
│ │   audit.py   GET (admin)                                     │   │
│ │   proxy.py   {svc}/{path} (viewer for safe / admin for unsafe│   │
│ │              + per-service auth injection)                   │   │
│ │              services: mlflow, prefect, ray, prometheus,     │   │
│ │              grafana, control_plane (Phase 12)               │   │
│ │   docs.py        /tree /content (viewer)                     │   │
│ │   approvals.py   GET /approvals, POST /approve/{id}          │   │
│ │                  POST /reject/{id} (admin) — proxies to CP   │   │
│ │   containers.py service controls (start/stop/restart,logtail)│   │
│ │   pipelines.py   GET /deployments /runs  POST /trigger       │   │
│ │                  (viewer/admin) — Prefect REST proxy         │   │
│ │   scaffold.py    POST /preview /create (admin)               │   │
│ │                  runs scaffold_model.py in container         │   │
│ │ secret_store.py   Fernet encrypt/decrypt facade              │   │
│ │ auth.py           JWT issue/verify, role gate                │   │
│ └──────────────────────────────────────────────────────────────┘   │
└──────────────┬─────────────────────────────────────────────────────┘
               │ asyncpg                                       │
               ▼                                               ▼
        ┌──────────────────────────┐         Upstream services
        │ Postgres                 │         MLflow / Prefect /
        │  dashboard_config        │         Ray / Prometheus /
        │  dashboard_audit         │         Grafana / MinIO /
        │  (Fernet ciphertext      │         Dataplane sim /
        │   in secret_value)       │         Control Plane (Phase 12)
        └──────────────────────────┘

Backend-for-Frontend (BFF) layer (F8)

The dashboard is moving to a Backend-for-Frontend surface: instead of the browser fanning out to individual services, it calls view-shaped /api/v1/* endpoints that the backend composes (aggregated, authorized, and resilient to partial upstream failure). See ADR 0058.

Browser ──GET /api/v1/overview──► routers/bff.py ──► bff.aggregate({meta, traffic, drift, audit})
                                     (viewer authz)        │  per-source timeout, run concurrently
                                   { meta:{…}, traffic:{…}, drift:{…}, audit:{…},
                                     _partial?: ["drift"] }   ← failed/slow sources listed, never a 500
  • bff.pyaggregate(sources, timeout) runs each source concurrently with its own timeout; a source that raises or times out is dropped and named under _partial, so a slow/down upstream yields a degraded-but-usable payload (the client renders a freshness badge) instead of an error page.
  • routers/bff.py — view endpoints (e.g. GET /api/v1/overview) compose their sources through aggregate. AuthZ (viewer role) is enforced here, per "authz MUST be enforced in the BFF" (F8 R2).
  • New view endpoints are added by defining source callables and composing them — the timeout/partial semantics come for free.

Realtime gateway (SSE)

Live surfaces ride a multiplexed Server-Sent-Events stream instead of polling (F8 R3–R7):

backend components ──bus.publish("job.started", {...}, tenant)──► realtime.EventBus
                                                                      │  channel + tenant filter
Browser ──GET /api/v1/stream?channels=job.*,drift.*──► routers/bff.py ┘  per-sub bounded queue
        ◄── event: job.started / data: {...} ── (keep-alive comments while idle)
  • realtime.py — a process-local EventBus. Subscribers register glob channel patterns (job.*, drift.*, alert.*, deploy.*, approval.*, event.*) and an optional tenant; a subscriber never receives another tenant's events (R4). Each subscription has a bounded queue; under a flood the oldest event is dropped and a dropped counter is bumped (backpressure, R7).
  • GET /api/v1/stream — viewer-gated SSE endpoint: emits a hello frame, then live events, with keep-alive comments while idle and disconnect-aware cleanup. Clients fall back to polling when the stream is unavailable (R5).
  • Publishers — backend routers call bus.publish(channel, data, tenant=…) at the point a change happens. The approvals router is wired first: a successful approve/reject emits approval.approved / approval.rejected, so any open dashboard updates without polling. New publishers are one bus.publish() call at the mutation site.
  • Client (frontend/src/lib/realtime.ts + useRealtime.ts) — native EventSource can't send an Authorization header, so live surfaces consume /api/v1/stream via fetch-streaming. parseSSEChunk() turns stream bytes into typed {event, data} frames (comment/keep-alive-aware, with a carried-over remainder); readEventStream() drives the reader and reassembles frames split across network reads; nextConnectionState() models live → reconnecting → polling. The useRealtime(channels, onEvent) hook wires these to fetch, returns the connection state, and reconnects with a fixed delay (escalating to polling so the surface falls back to REST, R5). <ConnectionBadge> renders that state via StatusPill (icon + label, never colour-only — F18).

MLOps console (F9)

The MLOps console surfaces the already-shipped MLOps backend — registry, drift, cost, traffic, promotion policy — as view-shaped /api/v1/mlops/* endpoints (ADR 0060). It reuses the F8 BFF substrate: every endpoint composes its sources through bff.aggregate, so a slow/down source degrades to a _partial payload instead of a 500, and viewer authz is enforced in the BFF.

Browser ──GET /api/v1/mlops/registry──► routers/mlops.py ──► mlops.registry_rows(platform.db)
        ──GET /api/v1/mlops/model/{name}─►               ──► mlops.model_detail(...)  (cost·drift·traffic·promotion tabs)
        ──GET /api/v1/mlops/promotion/{name}►            ──► mlops.promotion_check(...) → allow/deny + reasons
  • mlops.py — pure, view-shaped read helpers over platform.db:
  • registry_rows() composes the union of models seen across drift_snapshots, model_costs, traffic_rules and promotion_rules into ModelRow-shaped dicts (name × version × stage × health × freshness × governed). Health is a colour-blind-safe token (ok/warn/unknown, F3).
  • promotion_check() mirrors exa pipeline promote: allowed only when an enabled promotion policy exists; missing/disabled policy denies with an explicit reason (F9 R4), and the phase-11 approval step is always flagged so the human gate is never hidden.
  • model_detail() composes the detail-2.0 tabs (cost/drift/traffic/promotion).
  • Central name-casing (F9 R2). MODEL_REGISTRY is uppercase (JPCP), MLflow is lowercase (jpcp). The mapping lives in exactly one place — mlops.display_name() / mlops.mlflow_name() — so no caller special-cases it.
  • Client (frontend/src/lib/mlops.ts + pages/MlopsConsole.tsx) — a registry grid (selectable rows, StatusPill health, freshness) beside a guided promotion panel. promotionVerdict() is a pure, unit-tested helper: a blocked promotion always shows why, an eligible one still names the pending approval step. Skeleton while loading, EmptyState on empty/error, _partial surfaced as a warning pill.

Feature flags & staged rollout (F25)

A self-hosted feature-flag layer (ADR 0070) completing the F23 client seam: flags are evaluated server-side with the caller's context (tenant + role from F15 + a deterministic percentage bucket); the client receives decisions, not rules. Admin overrides persist and are audited (D4).

GET  /api/v1/flags        ─► evaluate_all(role, tenant, subject)  → {name: bool}   (decisions, R2)
GET  /api/v1/flags/admin  ─► admin_view  → defs + overrides + effective state      (admin, R4)
POST /api/v1/flags/{name} ─► set_override → feature_flag_overrides + audit_events + bus.publish("event.flag_changed")
evaluate(): default→override→role/tenant targeting→percentage bucket (admins bypass %; deterministic per subject, R5)
  • backend/feature_flags.py — the FLAG_DEFS registry (default + optional tenants/roles/ percentage targeting), subject_bucket() (stable SHA-256 0–99 bucket so a subject stays on one side of a rollout, R5), evaluate()/evaluate_all() (server-side contextual decisions, R1/R3), and set_override() (persists to feature_flag_overrides, audits to audit_events, R4).
  • routers/flags.py — viewer GET /api/v1/flags (decisions), admin GET /api/v1/flags/admin + POST /api/v1/flags/{name} (override → audit + event.flag_changed on the F8 bus for live kill-switch delivery, R2).
  • Client (frontend/src/lib/serverflags.ts + pages/Flags.tsx) — useFlagDecisions() + a useFlag(name) hook that prefers the server decision and falls back to the client default (flagFallback, pure/tested); an admin Flags page (useSetFlag mutation) to toggle flags. The F23 lib/flags.ts remains the offline default source.
  • Deferred (tracked in the plan): live push of decisions over F8 into useFlag (currently poll+invalidate), a full Refine CRUD targeting editor, and per-flag adoption/error telemetry to F24.

Alerting & incident (F12)

A unified alert inbox (ADR 0062) derived from the platform's own signals — prediction drift, budget overspend, and eval regressions — severity-coded (F3) and ack-able, with acks audited (D4) and published on the F8 alert.* channel so open dashboards update live.

GET  /api/v1/alerts ─► routers/alerts.py ─► bff.aggregate({inbox: alerts.active_alerts})
  drift  → drift_snapshots vs drift_baselines (z-score ≥3 critical, ≥2 warn)
  budget → project_budgets vs model_costs (consumed > budget → error)
  eval   → eval_results (latest run has a failed metric → warn)
POST /api/v1/alerts/{id}/ack ─► alerts.acknowledge → audit_events (D4) + bus.publish("alert.acked", …)
  • backend/alerts.pyactive_alerts() merges the three sources into severity-sorted, typed alert rows with per-severity counts (R1); each has a deterministic id (source:key). Drift alerts parse the baseline stats JSON and fire on a z-score threshold. acknowledge() audits the ack to audit_events (R3/D4).
  • routers/alerts.py — viewer-gated GET /api/v1/alerts (BFF-composed) and POST /api/v1/alerts/{id}/ack (audits + publishes alert.acked on the realtime bus).
  • Client (frontend/src/lib/alerts.ts + pages/Alerts.tsx) — the alert inbox with severity pills (pure severityToken/severityRank/inboxHeadline) and an Ack button (useAckAlert mutation, invalidates the inbox). New Alerts nav + lazy /alerts route.
  • Deferred (tracked in the plan): Alertmanager merge + incident correlation/timeline (R2), full ack/silence/snooze to Alertmanager (R3), on-call/escalation + notification channels (R4), inline runbooks + gated remediation (R5), and the SLO/error-budget burn-rate board (R6). The alert grid lands with F17.

Embedded copilot (F11)

A context-aware, guardrailed copilot in the shell that answers grounded questions and proposes (never executes) exa actions (ADR 0065). It is a thin BFF proxy to the existing Skipper agent bridge — the same OpenAI-compatible POST /v1/chat/completions that exa ask uses — so it does not introduce a second agent or model backend. Provider cost depends on the backend configured for Skipper.

CopilotPanel (shell drawer, every page) ─► POST /api/v1/copilot/ask ─► routers/copilot.py
  build_system_context(page/entity/filters)  ← F1 URL-state, framed as UNTRUSTED data (R2/R6)
  ask_copilot() ──httpx──► Skipper agent /v1/chat/completions  (degrades gracefully → _partial:["agent"])
  parse_response(): answer + extract_proposals(exa …) + extract_trace()
  audit_copilot() ─► audit_events (source=dashboard-copilot, D4)
answer ─► sanitizeMarkdown [F16] ─► render ; proposals ─► copy-only cards (NO run button, R5)
  • backend/copilot.py — everything except the network call is pure/unit-tested. build_system_context injects the current page/entity/filters and explicitly frames page content as untrusted ("treat as data, never instructions") to blunt prompt injection (R6/GWT-6). extract_proposals pulls exa … commands out of the answer and flags each requiresApproval when it names a mutating subcommand (retrain/promote/approve/traffic/…) — so the UI forces human confirmation + the approval flow (R5). ask_copilot calls the bridge with an injectable transport (so tests run without a live agent) and degrades to a well-formed _partial:["agent"] envelope instead of a 500.
  • routers/copilot.py — viewer-gated POST /api/v1/copilot/ask; assigns a server-controlled conversation ID from the signed login token and attempts to append each query to the shared audit store. Audit persistence is best-effort, so an unavailable store does not make the question fail. There is deliberately no execution endpoint: proposals are advisory data (R5).
  • frontend/components/CopilotPanel.tsx — a right-drawer launcher available on every page; the drawer traps focus (F18 useFocusTrap). Answers render through sanitizeMarkdown (F16); proposals are copy-only cards with an approval-gate badge (no run button); the agent trace is collapsible (R6). lib/copilot.ts derives grounding context from the route (/models/jpcp → entity) and posts via a TanStack mutation.
  • Deferred (tracked in the plan): streaming over the F8 WS channel (R1 — currently single-shot), NL→in-app-view actuation (R3 — answers are text + copy-able commands today), one-click confirm that routes a proposal straight into the approval gate, and richer inline agent-trace (C4/AgentOps).

Compose now starts an agent service and sets the dashboard's AGENT_URL to its service name. The Helm chart wires the same internal URL. Both tiers must share AGENT_API_KEY when authentication is enabled, and Skipper still needs a usable model backend. A transport or backend failure produces a stable _partial: ["agent"] response; it is not evidence that the agent path is healthy.

LLMOps console (F10)

An LLMOps surface (ADR 0064) over the LLM-serving substrate: the llm_endpoints registry and the continuous-eval tables (eval_runs/eval_results). Backends not yet wired degrade gracefully.

GET /api/v1/llmops/overview ─► routers/llmops.py ─► bff.aggregate({endpoints, evals})
  endpoints → llmops.endpoints     (engine · HF model · tensor-parallel · dtype · enabled, R2)
  evals     → llmops.eval_summary  (latest eval run per model + metric results + passRate, R1/C2)
  • backend/llmops.pyendpoints() lists the LLM endpoint registry; eval_summary() returns the newest eval run per model with its metric rows (value / baseline / passed) and a computed passRate. Missing tables degrade to empty payloads (F10 R6).
  • routers/llmops.py — viewer-gated, BFF-composed /api/v1/llmops/overview.
  • Client (frontend/src/lib/llmops.ts + pages/Llmops.tsx) — an endpoint registry table and per-model eval cards (metric pass/fail + passRate pill via pure evalTone/passRateLabel). A footnote names the not-yet-available surfaces. New LLMOps nav + lazy /llmops route.
  • Deferred (tracked in the plan): prompt studio with version/diff/rollback + sanitized editor (R1), LiteLLM gateway routing/rate-limit/fallback + cost→model_costs (R2), semantic-cache metrics (R3), RAG-ops pipeline + retrieval quality (R4), and vector-DB/embedding-lifecycle views (R5).

Governance & compliance (F14)

An honest, evidence-based governance surface (ADR 0063) over the shipped compliance backend (compliance_records, model_cards, audit_events). Every view reports evidence coverage, not certification — gaps are surfaced, never false green.

GET /api/v1/governance/overview ─► routers/governance.py ─► bff.aggregate({posture, compliance, cards, audit})
  posture    → governance.nist_posture       (NIST AI RMF controls graded satisfied/partial/GAP from evidence, R1)
  compliance → governance.compliance_status  (per-model risk class · Annex-IV technical file · provenance, R2)
  cards      → governance.model_card_coverage (which models have a card — honest gap list, R5)
  audit      → governance.audit_integrity     (rolling SHA-256 hash-chain → head digest + verified badge, R3)
  • backend/governance.py — pure helpers. nist_posture() derives control status from real signals (change-approval satisfied only if approval audit events exist; model documentation graded by card coverage; risk classification by compliance records) — no false green (R1). compliance_status() reports per-model EU-AI-Act risk class + technical-file/provenance presence (R2). audit_integrity() computes a deterministic SHA-256 hash-chain over ordered audit events and returns the headDigest — an external copy of the digest detects any tampering of a past event (R3). model_card_coverage() lists carded vs uncarded models (R5).
  • routers/governance.py — viewer-gated, BFF-composed /api/v1/governance/overview.
  • Client (frontend/src/lib/governance.ts + pages/Governance.tsx) — a NIST posture list (satisfied/partial/gap StatusPills + evidence), an EU-AI-Act table, model-card coverage, and an audit-integrity badge with the short head digest. Pure postureToken/coverageLabel/digestShort unit-tested. Admin-only Governance nav + /governance route.
  • Deferred (tracked in the plan): policy-as-code dry-run + decision logs (R4), supply-chain signature/AI-BOM/SLSA + fairness subgroup slices (R5), approvals-2.0 with diff+evidence, and one-click compliance-export PDF (R6). The audit explorer grid lands with F17.

Self-observability (F24)

The dashboard observing itself (ADR 0067) — an always-available, dependency-free core with no third-party egress: request/latency self-metrics, a BFF-checked dependency-health status page, and UI-action auditing.

every response ─► MetricsMiddleware ─► selfobs.METRICS (requests · 4xx/5xx · 429 · p50/p95 latency)
GET  /api/v1/selfobs/status ─► selfobs.status_payload  → {status, dependencies[], metrics}   (status page, R5)
POST /api/v1/selfobs/action ─► selfobs.record_ui_action → platform_db.audit_events            (UI audit, R4/D4)
frontend: telemetry.scrubPii(...) strips email/JWT/bearer/hex before anything is reported (R1)
  • backend/selfobs.py — a process-local Metrics collector fed by MetricsMiddleware (counts requests, 4xx/5xx, 429 rate-limit hits, and a bounded p50/p95 latency window); dependency_health() probes platform.db reachability + latency for the status page (R5); record_ui_action() audits a UI action to audit_events (R4 / D4), degrading quietly if the table is absent.
  • routers/selfobs.py — viewer-gated GET /api/v1/selfobs/status (health + metrics) and POST /api/v1/selfobs/action (UI audit).
  • frontend/src/lib/telemetry.tsscrubPii() (pure, unit-tested — strips email/JWT/bearer/long-hex before any report leaves the browser, R1), reportAction() (audits via the backend — no third-party endpoint, R3), and useSelfObsStatus(). pages/SelfObs.tsx is the in-app status page (KPI tiles reusing F4 <KpiTile> with error/rate-limit thresholds, dependency list) at /status.
  • Deferred (tracked in the plan): GlitchTip self-hosted JS error tracking (R1), OTel browser tracing correlated through the BFF to Tempo (R2), self-hosted anonymized analytics (R3), web-vitals → Prometheus RUM (R4), and Playwright synthetic monitoring of critical flows → F12 alerts (R6).

FinOps & Green-AI (F13)

A cost + carbon surface (ADR 0066) rendering the shipped phase 23/24 cost/carbon backend (model_costs, project_budgets, carbon_records), honest about estimation uncertainty. Carbon figures are computed by pluggable providers (ADR 0074): carbon_summary reports which provider(s) produced the records and, when a single provider is in use, surfaces its methodology + uncertainty (resolved best-effort from examlops.providers) instead of a hardcoded default.

GET /api/v1/finops/overview ─► routers/finops.py ─► bff.aggregate({cost, budget, carbon, unitEconomics})
  cost   → finops.cost_rollup      (per-model GPU-hours + USD + facility totals, R1)
  budget → finops.budget_status    (budget-vs-actual + overBudget flag, R2)
  carbon → finops.carbon_summary   (kWh + gCO₂e totals + methodology + ±uncertainty + providers, R3)
  unit   → finops.unit_economics   (cost-per-training-run, R4)
  • backend/finops.py — pure, view-shaped helpers over the cost/carbon tables. Carbon payloads always carry a methodology string + uncertainty fraction (±30%) so the UI shows a band, never false precision (R3). Missing tables degrade to zeros/empties, never a 500.
  • routers/finops.py — viewer-gated, BFF-composed /api/v1/finops/overview.
  • Client (frontend/src/lib/finops.ts + pages/Finops.tsx) — KPI tiles (reusing the F4 <KpiTile>), a per-model cost table, a budget list with an over-budget StatusPill, and the carbon methodology footnote. Pure usd/budgetPct/carbonLabel are unit-tested. New FinOps sidebar entry + lazy /finops route (F23).
  • Deferred (tracked in the plan): cost-allocation Sankey + drill-through (R1), burn-rate forecast + overspend alert wiring to F12 (R2), SCI/energy-mix trend + facility PUE (R3), gated waste reclaim (R5), and chargeback export (R6). Per-project cost attribution lands with the F15 tenant columns.

AuthZ & multi-tenancy (F15)

A capability-based authorization affordance layer + tenant scoping over the existing 2-role JWT (ADR 0057). The BFF stays the sole enforcement point; the UI treats capabilities as affordance only. This is the seam a later OIDC/OpenFGA migration slots into.

JWT{role,tenant} ─► capabilities.principal_from_claims ─► {sub, role, tenant, capabilities[]}
GET /api/auth/me ─► returns role + tenant + capabilities[]   (the UI affordance source)
require_capability('model.promote') ─► 403 unless role holds it   (BFF enforcement, R2)
tenant scoping: tenant_visible / assert_tenant_access / scope_to_tenant   (default-deny; cross-tenant admin ok, R4)
UI: useCapabilities() ← /me · <CapabilityGate capability> hides/disables + explains (R3) · tenant badge (R4)
  • backend/capabilities.py — a role → capability set catalogue (viewer = view+search; admin adds model.promote, approval.decide, retrain.trigger, config.write, secret.reveal, service.control, …). capabilities_for/can/deny_reason (R3 explains denials); require_capability(cap) is the BFF enforcement dependency (R2); STEP_UP_CAPABILITIES flags actions that will require step-up/MFA (R6, enforcement deferred). Tenant scoping (R4, default-deny): tenant_visible, assert_tenant_access (403 cross-tenant), scope_to_tenant. principal_from_claims defaults a missing tenant claim to "default" (single-tenant case).
  • GET /api/auth/me now returns tenant + capabilities[], so the UI reflects exactly what the backend grants.
  • frontend/src/lib/capabilities.tsuseCapabilities() (from /me) + pure can/reason/ requiresStepUp. <CapabilityGate capability> renders a control only when held, else a dimmed, explained affordance (never a silent dead control, R3). A tenant badge in the shell surfaces the active tenant (R4 groundwork).
  • Deferred (tracked in the plan): OIDC/Keycloak Auth-Code+PKCE replacing the shared password (R1), OpenFGA relationship checks (R2), a tenant switcher + per-entity tenant columns (R4), service-account /API-token + session management (R5), and step-up/MFA enforcement (R6).

Frontend engineering baseline (F23)

A typed, resilient, code-split frontend baseline (ADR 0052) hardening the existing client:

apiFetch ─► on !ok: throw ApiError(status, parseProblem(body))   (RFC 7807 / FastAPI detail → typed)
QueryClient.retry = shouldRetry(n, err)   ← never retry a 4xx ApiError
<ErrorBoundary><Suspense fallback=Skeleton><Routes/>   ← crash → designed fallback, not a white screen
route imports = lazy() (ModelDetail/Config/Docs/…/consoles)   ← code-split, smaller initial bundle (R6)
new routes gated: isEnabled('mlopsConsole' | 'facilityConsole')   ← feature flags (R7 / F25 seam)
  • lib/errors.tsApiError (typed status + parsed Problem) and parseProblem() which accepts RFC 7807 problem+json, FastAPI {detail} (string or validation array), or falls back (R2). shouldRetry() centralizes the retry policy: never retry a 4xx. apiFetch throws ApiError; the QueryClient uses shouldRetry.
  • components/ErrorBoundary.tsx — a class error boundary rendering a designed EmptyState fallback with a retry button, wrapped around the route outlet so one page's crash never blanks the shell.
  • Code-splitting — heavy routes are lazy()-imported behind a <Suspense> skeleton (R6).
  • lib/flags.ts — a typed feature-flag registry; resolveFlag() layers localStorage → env → default. New surfaces (/mlops, /facility) are gated so they can be rolled out/killed without a redeploy (R7); the full staged-rollout engine is F25.
  • Deferred (tracked in the plan): OpenAPI-generated TS client + CI contract gate (R1/R3), Playwright E2E journeys + Storybook visual-regression + axe a11y in CI (R4/R5), bundle-size budget (R6), per-PR preview deploys (R7).

Accessibility — WCAG 2.2 AA (F18)

The dashboard targets WCAG 2.2 AA (ADR 0068), realized mostly through the F3 design system and reusable primitives rather than per-page retrofits. This slice ships the keyboard/screen-reader/contrast substrate + first adoptions; the remaining conformance work rides on F3/F2/F4.

lib/a11y.ts (pure): parseOklch · oklchLuminance · contrastRatio · oklchContrast · meetsAA/meetsAAA · prefersReducedMotion · tabbableWithin
hooks/useFocusTrap(ref, active)   ← Tab-trap + focus restore for dialogs/drawers (R2)   ← adopted by CommandPalette
hooks/announcer.ts + useAnnouncer.tsx  ← polite/assertive aria-live region + announce() (R3)  ← RouteAnnouncer announces page changes
components/SkipLink                ← skip-to-#main link (R2)   ← Layout + <main id="main" tabIndex=-1>
index.css @media (prefers-reduced-motion) ← animations/transitions neutralized app-wide (R5)
test lib/a11y.test.ts             ← audits the real F3 OKLCH tokens meet AA (R4) — a dep-free axe stand-in
  • lib/a11y.ts — WCAG contrast math on the actual OKLCH tokens: oklchLuminance converts OKLCH → OKLab → linear sRGB → relative luminance, so oklchContrast/meetsAA verify AA on the real F3 palette (R4) rather than a hex approximation. The token audit runs in CI as a dependency-free stand-in for axe-core (which stays deferred, R7). tabbableWithin lists focus-trappable elements.
  • useFocusTrap(ref, active) — traps Tab/Shift-Tab within a container and restores focus to the triggering element on close (R2); adopted by the ⌘K CommandPalette dialog.
  • useAnnouncer / AnnouncerProvider — a single visually-hidden aria-live region (polite + assertive) mounted at the app root; announce() clears-then-sets so repeat strings re-announce without stealing focus (R3). A RouteAnnouncer announces the page on navigation; live F8 pushes can announce through the same hook. (Hook lives in hooks/announcer.ts so the provider file exports a component only — avoids a fast-refresh warning.)
  • SkipLink + <main id="main"> — keyboard users skip the nav straight to the main landmark (R2).
  • Reduced motion (R5) — a global @media (prefers-reduced-motion: reduce) rule neutralizes animations/transitions/smooth-scroll. Status is never colour-only (F3 icon+label) and contrast is AA-audited (R4).
  • Deferred (tracked in the plan): axe-core in CI (R7 — dep-adding; the token audit is the interim guard), a full manual audit with tracked issues (R1/GWT-1), chart data-table fallbacks everywhere (F4 already ships <ChartFrame>), and focus-trap adoption across all remaining dialogs/drawers.

Internationalization & localization (F19)

A dependency-free i18n layer (ADR 0071) — externalized namespaced catalogs (EN + Italian), Intl-based locale-aware formatting, and a shared HPC-unit formatter reused across F6/F13 (R3). The spec named react-i18next; this ships the same capability without the dependency (keeping the build lean), with the catalog structure ready to add locales.

lib/i18n.ts (pure): CATALOGS{en,it} · translate(key,vars) · translatePlural(Intl.PluralRules) ·
  formatNumber/Percent/Date/RelativeTime(Intl) · formatHpcUnit(FLOPS·B/s·W·Wh·gCO2e·GPU-h, SI-scaled) ·
  formatInTz + utcTooltip · detectLocale · localeDir
hooks/i18nContext.ts (useI18n/useT) + hooks/I18nProvider.tsx (locale state · localStorage · <html lang/dir>)
components/LocaleSwitcher  ← in Layout ; consumers: FinOps (formatHpcUnit GPU-h + t() copy)
  • lib/i18n.ts — pure catalogs + formatters. translate does dot-path lookup with {{var}} interpolation and EN fallback + missing-key warn (R1 observability); translatePlural selects _one/_other via Intl.PluralRules (correct plurals per locale). All number/date/percent/relative formatting goes through Intl (R3/GWT-2). formatHpcUnit is the single HPC formatter (SI-scales FLOPS/B·s⁻¹/W/Wh/gCO₂e; leaves GPU-hours natural) shared by F6/F13 (R3/GWT-3). formatInTz + utcTooltip give user-TZ display with a UTC reference (R4).
  • I18nProvider — holds the active locale (detected from the browser, persisted to localStorage) and keeps <html lang>/dir in sync so layout is RTL-safe via F3 logical properties (R5). The hook lives in i18nContext.ts (component-only provider file — the react-refresh split).
  • LocaleSwitcher — in the shell; switching re-renders all copy through the provider (GWT-1). Adopted first on FinOps (GPU-hours via formatHpcUnit, headings via t()).
  • Deferred (tracked in the plan): a CI guard against hardcoded strings (R1/GWT-6 — needs an eslint plugin/dependency), a pseudo-localization overflow test (R5/GWT-5), full catalog coverage beyond the proof surface, and the F6 facility-local TZ toggle (R4). Locale preference persistence graduates to F21 workspaces.

Responsive, multi-device & NOC wall (F20)

Form-factor support (ADR 0069): responsive breakpoints laptop→ultrawide, a chrome-less NOC/wall kiosk mode, detachable live panels, and a print stylesheet for clean reports — all dependency-free.

lib/responsive.ts (pure+hooks): BREAKPOINTS · matchBreakpoint · isKioskMode(?kiosk=1) · nextRotationIndex ·
  popOut/popOutFeatures · useMediaQuery · useBreakpoint · useRotator
lib/noc.ts (pure): buildNocSlides(finops, alerts, locale) → curated slides (degrade to "—", never error)
pages/NocWall (/noc): fixed full-screen · dark · big-font · auto-rotating (useRotator) · Exit link · no nav (R2)
components/PopOutButton: window.open a live panel into its own window (R5)
index.css @media print: hide .no-print chrome (nav/copilot/pop-out) → clean F13/F14 reports (R6)
  • lib/responsive.tsmatchBreakpoint classifies a width into laptop/desktop/wide/ultrawide (R1); useBreakpoint/useMediaQuery are jsdom/SSR-safe (seed in the initializer, subscribe in the effect — no set-state-in-effect); useRotator auto-advances a wrap-around index for the NOC carousel (R2); popOut detaches a panel into its own window (R5).
  • NOC wall (/noc) — a fixed full-screen, dark, big-font view that auto-rotates curated slides (spend / alerts / carbon, composed by the pure buildNocSlides) every 12 s with a live clock and no interaction chrome. It renders inside the authed app, so an unattended wall never drops to a login (long-lived viewer token, F15/R2); an Exit link always returns to the app. Missing data degrades to "—" — a wall never shows an error page.
  • Print (R6) — a @media print stylesheet hides .no-print chrome (nav rail, copilot launcher, pop-out buttons) and drops backgrounds so governance (F14) / finance (F13) pages print clean.
  • Deferred (tracked in the plan): a full PWA (manifest + service worker, offline shell + cached last-known data with a stale badge, R4 — needs vite-plugin-pwa), touch/tablet density tuning (R3 builds on F3), and richer pop-out panel wiring (R5 currently opens a URL).

Personalization, workspaces & onboarding (F21)

Per-user personalization (ADR 0072): preferences, a watchlist, a first-run guided tour, and a contextual help drawer + glossary — self-hosted, dependency-free, persisted in localStorage this slice.

lib/prefs.ts (pure+hooks): refKey · togglePinInList · pushRecentInList (unit-tested)
  usePrefs (defaultLanding · density) · useWatchlist (pin/unpin) · useOnboarding (run-once)   [localStorage]
lib/glossary.ts: GLOSSARY + searchGlossary
components/OnboardingTour  ← first-run modal, once until completed (R5) ; HelpDrawer ← `?` glossary (R6)
components/PinButton       ← star an entity onto the watchlist (R4)
pages/Preferences (/preferences): default landing · density · language (F19) · watchlist · replay tour
App Home: honours prefs.defaultLanding on `/` (R3)
  • lib/prefs.ts — the store. Pure list helpers (togglePinInList, pushRecentInList dedup+cap) are unit-tested; usePrefs/useWatchlist/useOnboarding read-modify-write the whole store so slices never clobber each other. usePrefs persists the default landing page + density (R3); useWatchlist pins entities (R4); useOnboarding gates the tour to run once (R5/GWT-5).
  • OnboardingTour — a first-run, self-hosted multi-step modal (no external tour SaaS) that completes once and can be replayed from Preferences. HelpDrawer — a ?-triggered contextual drawer with a searchable glossary (R6/GWT-6), focus-trapped (F18).
  • Preferences (/preferences) — the preference center: default landing, density, language (the F19 switcher), the watchlist, and a replay-tour control. The app Home route honours defaultLanding so a user lands where they chose (R3); fresh users keep Overview.
  • Deferred (tracked in the plan): the BFF UI-state store for cross-device sync (R1), the drag-drop widget-grid home + widget library and persona default layouts (R1/R2) with export to the F20 NOC kiosk, watchlist change notifications over F8/F12 (R4), and per-persona flag-tied tours (R5/F25). This slice persists locally.

Collaboration & workflow (F22)

Collaboration primitives (ADR 0073) attached to platform entities: comments/annotations with @-mentions, an entity activity trail, and shareable time-frozen snapshots behind scoped, expiring, read-only tokens. Everything is tenant-scoped (F15), sanitized (F16), and audited (D4).

backend/collab.py (own additive tables: entity_comments · share_snapshots — CREATE IF NOT EXISTS, never ALTERs the CLI schema)
  extract_mentions · sanitize_comment (pure) · add_comment/list_comments (tenant-scoped) · entity_activity (comments+audit) · create/get_snapshot (expiring, read-only)
routers/collab.py (viewer, tenant via principal_from_claims):
  GET/POST /api/v1/collab/{type}/{id}/comments · GET …/activity · POST /snapshot · GET /snapshot/{token}
  @-mention → bus.publish("event.mention") [F12] ; every write → audit_events (source=dashboard-collab, D4)
frontend: lib/collab.ts (useComments/useAddComment/useCreateSnapshot + pure extractMentions/snapshotShareUrl)
  components/CommentThread (sanitized render, mention preview) · ShareSnapshotButton (copy frozen-view link) → adopted on ModelDetail
  • backend/collab.py — pure extract_mentions/sanitize_comment (strip HTML + javascript:, R6) are unit-tested. add_comment sanitizes, extracts mentions, persists tenant-scoped, and audits (R1/R5); list_comments is tenant-scoped (R1/F15); entity_activity merges comments with the entity's audit events into one trail (R5/GWT-5). create_snapshot mints a secrets.token_urlsafe id with an expiry and stores the frozen view; get_snapshot returns it read-only and only while unexpired (R2/GWT-3). The dashboard owns two additive tables — it never alters the CLI's platform_db schema.
  • routers/collab.py — viewer-gated; tenant + author come from principal_from_claims (F15). @-mentions publish event.mention on the F8 bus (F12/GWT-1). There is no cross-tenant read and no write path on a snapshot token.
  • FrontendCommentThread (adopted on ModelDetail) renders comments through sanitizeMarkdown (F16/GWT-6) and previews who a draft will notify; ShareSnapshotButton creates a snapshot of the current URL-state and copies the shareable link.
  • Deferred (tracked in the plan): the BFF collaboration store graduating to cross-device, external read-only share pages rendering a snapshot token, approval discussion threads captured with the decision (R4, extends the F14 approval gate), and external push to Notion/issue trackers behind a flag (R6/F25).

Security hardening (F16)

A frontend + BFF security-hardening baseline (ADR 0053): strict security headers on every response, a rate-limit guard on expensive UI-driven queries, and defense-in-depth markdown sanitization.

every response ─► SecurityHeadersMiddleware ─► CSP(frame-ancestors='self' + grafana[F5]) · HSTS · X-Content-Type-Options · Referrer-Policy · Permissions-Policy
/api/v1/search ─► Depends(rate_limit(RateLimiter)) ─► 429 on flood (fixed-window, per-client)
markdown (Docs · README) ─► sanitizeMarkdown() ─► react-markdown (raw HTML already inert)
  • backend/security.pySecurityHeadersMiddleware attaches the static header set (build_csp scopes frame-src/frame-ancestors to the Grafana embed origin so F5 panels load while nothing else can iframe the dashboard, R1). RateLimiter is an in-process fixed-window limiter; the rate_limit(limiter) dependency returns 429 past the limit (R7). Registered in main.py; the search endpoint (fan-out per keystroke) is the first guarded route.
  • frontend/src/lib/sanitize.tssanitizeMarkdown() strips <script>/<style>/framing tags, inline on*= handlers, and javascript:/data:text/html URIs before rendering; safeUrl() guards hrefs (R2). react-markdown already renders raw HTML inert (no rehype-raw), so this is a belt-and-suspenders layer — wired into the Docs and Model README renderers. Introducing unsanitized dangerouslySetInnerHTML is disallowed (none exists in the tree).
  • Deferred (tracked in the plan): report-only→enforce CSP rollout with nonces, CSRF tokens (R3), role-gated audited secret reveal (R4 — config secrets are already masked at rest), server-side PII redaction in log/audit views (R5), and CI SCA/dependency scanning + SRI (R6).

Visualization system (F4)

A themed, dependency-free in-product chart toolkit (ADR 0055) — reusable primitives the domain consoles compose, deliberately not duplicating F5/Grafana's heavy time-series. Every chart is colour-blind-safe (colour comes from F3 status tokens, always paired with a label/value) and ships an accessibility fallback.

lib/viz.ts (pure): thresholdTone · histogram · sparklinePoints · formatDelta · ciLabel · toneStatus
components/viz/:
  <ChartFrame ariaLabel title table/>   ← required a11y wrapper: aria-label + <details> data-table (F4 R7/F18)
  <KpiTile value delta trend threshold/> ← threshold-coloured KPI + inline sparkline (F4 R2)
  <Distribution values bins/>           ← histogram (F4 R5) inside a ChartFrame
  <Uncertainty variants=[{mean,ci}]/>   ← dot-and-whisker CI/error-bar chart (F4 R5, A/B & eval)
consumed by: F6 (facility KPIs) · F9 (drift distribution / A/B) · F13 (cost) · F14 (fairness)
  • lib/viz.ts — all chart math/formatting as pure functions, unit-tested in isolation: thresholdTone() maps a value to ok/warn/crit against thresholds (with higher-worse / lower-worse direction); histogram() bins a series; sparklinePoints() fits a series to an SVG polyline; ciLabel()/formatDelta() format uncertainty/deltas.
  • <ChartFrame> — the required wrapper (F4 R7): an aria-label'd figure that always renders a keyboard-reachable <details> data-table fallback, so no chart is pixels-only.
  • <KpiTile> — value + signed delta + inline sparkline, tinted by a threshold tone resolved through the F3 status tokens (theme-aware, colour never the only cue). Wired into the F6 facility console (queue-depth threshold) as the first consumer.
  • <Distribution> / <Uncertainty> — a histogram and a dot-and-whisker CI chart; both render their series/means+CIs in the ChartFrame data table.
  • Deferred (tracked in the plan): @xyflow lineage/topology DAGs, brush-zoom / hover-sync / click-to-drill, PNG/CSV export, worker-offloaded layout — the richer F4 surfaces build on these primitives.

Data grid & bulk operations (F17)

A reusable, dependency-free table primitive (ADR 0061) — sort, facet-filter, paginate, row-select, audited bulk actions, and CSV export — that every list surface composes instead of hand-rolling. Like F4/viz, all non-trivial logic is pure and unit-tested; the component stays thin.

lib/datagrid.ts (pure): applySort · applyFilter · computeFacets · queryRows · toggleSelection · toCsv · encodeQuery/decodeQuery
  ListQuery { sort[], filters, page, pageSize } → ListResult { rows, total, pageCount, facets }   ← the shape a BFF list endpoint serves (R2)
components/DataGrid.tsx: sortable headers · facet chips · density toggle (persisted) · selection + bulk-action bar · CSV export
first consumer: F13 FinOps "cost by model" (sortable + exportable)
  • lib/datagrid.ts — the query envelope (ListQueryListResult) matches what a server-side list endpoint should return, so a grid can move from client-side to server-side without an API-shape change (R2). applySort is a stable multi-column sort; computeFacets counts facet values over the filtered rows (R3); toCsv RFC-4180-escapes cells (R6); encodeQuery/decodeQuery round-trip sort+filters+page through URL params for shareable/deep-linkable views (R3).
  • <DataGrid> — generic over the row type: sortable headers (asc→desc→clear), facet filter chips with live counts, a density toggle persisted to localStorage (R1), row-selection checkboxes, and a bulk-action bar. Bulk actions require an inline confirm before firing and run against the selected rows — never a silent mass mutation (R4). A Column may supply an optional render for display while accessor stays the scalar used for sort/filter/facet/export (so numeric columns sort numerically even when shown formatted).
  • Deferred (tracked in the plan): row virtualization for very large sets, server-side query execution wired to a BFF endpoint, saved views (R5) persisted per user, column show/hide + reorder, and Parquet export — the current slice ships the client-side grid + first adoption.

Command palette & global search (F2)

A ⌘K command palette gives fuzzy navigation to any page/entity, role-scoped actions with exa-CLI equivalents (GUI↔CLI parity), and a federated /search backend that fans out across the platform's entity sources (ADR 0056).

⌘K ─► <CommandPalette> ─┬─ rankCommands(query, role)  → nav + actions (F15-scoped, cliEquivalent)
                        └─ useSearch(query)  ──debounced/cancelable──► GET /api/v1/search?q=
GET /api/v1/search ─► routers/search.py ─► search.search(platform.db, q)
                        fan-out: pages · models[mlops] · jobs[facility] · audit   → grouped·ranked·entity-linked
  • search.py — one search() federates over sources, reusing the F9/F6 read layers (mlops.registry_rows, the hpc_jobs/audit_events tables) plus a static page set, so there is one source of truth per entity kind (F2 R3). Results are typed (kind/source), each carries its F1 entity url, and are ranked by a dependency-free score() (exact > prefix > word-boundary > substring > fuzzy subsequence). Missing tables degrade to no results for that source, never an error.
  • routers/search.pyGET /api/v1/search?q= is viewer-gated and BFF-composed (_partial-safe).
  • lib/commands.ts — the command registry {id, label, to?, scopes?, group, cliEquivalent?}; visibleCommands(role) hides unauthorized commands (F15, F2 R2). Actions expose their exa equivalent for copy-as-CLI (F2 R6).
  • lib/search.ts — a client mirror of score() (fuzzyScore) so palette command ranking matches server ranking; rankCommands(); and the debounced, cancelable useSearch() hook (AbortController per keystroke, F2 R4).
  • <CommandPalette> — ⌘K toggles it anywhere; ↑/↓ move, Enter selects (navigate or copy-CLI), Esc closes. Registered commands rank first, federated results stream in below, grouped by source. Mounted once in Layout.

Facility console (F6)

The facility console renders the phase-23 scheduler abstraction (mock / Slurm / Flux) as a scheduler-neutral HPC overview: node/GPU allocation, per-partition utilization, and the job queue (ADR 0059). Like F9, it reuses the F8 BFF substrate and is viewer-gated + partial-failure safe.

Browser ──GET /api/v1/facility/overview?cluster=─► routers/facility.py ──► facility.facility_overview(platform.db)
        ──GET /api/v1/facility/queue?cluster=────►                     ──► facility.job_queue(...)   (longest-wait first)
        ──GET /api/v1/facility/job/{id}──────────►                     ──► facility.job_detail(...)  (+ mlflow_run_id cost link)
  • facility.py — pure, view-shaped read helpers over the hpc_jobs table:
  • facility_overview() sums node/GPU asks of running jobs vs queue depth, and reports per-partition (per-scheduler) running/queued/GPU counts (F6 R1).
  • job_queue() returns waiting jobs longest-wait-first (queue_seconds proxies priority/backfill since the table has no fair-share column, F6 R2).
  • job_detail() carries resources, timing, exit code, and the mlflow_run_id cost link.
  • Multi-cluster (F6 R6): scheduler == cluster; every helper takes an optional filter so the UI's cluster switcher rescopes all lists through one code path.
  • Graceful (F6 R7): a missing hpc_jobs table / no rows yields zero-valued "no data" payloads, never a 500.
  • Client (frontend/src/lib/facility.ts + pages/FacilityConsole.tsx) — KPI cards, a partition list with a colour-blind-safe pressure token (partitionTone: queue > running ⇒ "Backlog"), a cluster switcher (shown only when >1 cluster), and the queue table. Pure waitLabel() / partitionTone() are unit-tested. Skeleton while loading, EmptyState on empty/error.

Grafana embed layer (F5)

Heavy time-series are owned by Grafana and embedded into the React pages as themed, config-driven panels (ADR 0054) — the app never re-plots what Grafana already renders.

lib/grafana.ts: GRAFANA_PANELS registry {name → {uid, panelId}}  +  base URL (VITE_GRAFANA_URL / backend public URL)
   │  buildPanelUrl(base, panel, {theme,timeRange,vars})  → {base}/d-solo/{uid}?panelId=…&kiosk&theme=…&from&to&var-*
<GrafanaPanel name theme timeRange vars/> ──► lazy iframe · Skeleton while loading · EmptyState fallback when unconfigured
  • lib/grafana.ts — a typed GRAFANA_PANELS registry (UIDs/panel-ids never hardcoded in components, R2) and the pure buildPanelUrl() (unit-tested — it's the security-sensitive part). Returns null when no base URL is configured so the component degrades instead of showing a broken frame.
  • <GrafanaPanel> — builds the d-solo iframe URL from the registry, shows a <Skeleton> while the frame loads, and an accessible <EmptyState> fallback when Grafana isn't configured/reachable. Lazy-loaded (loading="lazy"), theme + time-range + template-vars driven (R1/R4).
  • Auth/security — internal/lxp deployments may use anonymous Viewer; exposed deployments use a service-account token/proxy, and the embed origin is scoped by frame-ancestors (owned by F16).

Sequence: login

Browser                                Backend
  │ POST /api/auth/login {password}      │
  │─────────────────────────────────────▶│
  │                            check_password() (constant-time)
  │                            issue_token(role)
  │              200 {token, role, expires_at}
  │◀─────────────────────────────────────│
  │ localStorage.setItem('dashboard_auth', ...)

Sequence: secret write (admin)

Browser                            Backend                     Postgres
  │ PUT /api/config                  │                            │
  │  {grafana_api_key: "g-key"}      │                            │
  │ Authorization: Bearer <jwt>      │                            │
  │─────────────────────────────────▶│                            │
  │                                  │ verify_token() → role=admin
  │                                  │ encrypt("g-key")           │
  │                                  │ UPDATE dashboard_config    │
  │                                  │   SET secret_value=...     │
  │                                  │─────────────────────────────▶
  │                                  │ INSERT dashboard_audit     │
  │                                  │   (admin, set, grafana_api_key)
  │                                  │─────────────────────────────▶
  │           200 {grafana_api_key: "***", ...}
  │◀─────────────────────────────────│

Sequence: proxied request with auth injection

Browser                       Backend (proxy.py)          Grafana
  │ GET /api/proxy/grafana/api/dashboards
  │ Authorization: Bearer <jwt>      │                      │
  │─────────────────────────────────▶│                      │
  │                  verify_token() → role=viewer ok        │
  │                  fetch grafana_api_key, decrypt()       │
  │                  inject Authorization: Bearer g-key     │
  │                                  │ GET /api/dashboards  │
  │                                  │─────────────────────▶│
  │                                  │     200 [...]        │
  │                                  │◀─────────────────────│
  │           200 [...]              │                      │
  │◀─────────────────────────────────│                      │

Threat model

Attack Mitigation in scope Out of scope
Stolen JWT (XSS) None inherent; no third-party scripts in SPA. Future: httpOnly cookies. Browser-side XSS hardening beyond what React provides.
DB dump leaked alone Fernet ciphertext is opaque without the key. Re-keying secrets after a leak — manual via re-entry.
DB dump + env file leaked together Approach 3 (Vault) — not implemented; flagged as future work.
Brute-force login None at this layer. Rate limiting, fail2ban-style throttles — operator's reverse proxy.
Lost DASHBOARD_SECRET_KEY Fail-fast on boot; documented re-entry procedure. Automatic recovery (impossible by design).