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.py—aggregate(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 throughaggregate. 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-localEventBus. 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 adroppedcounter is bumped (backpressure, R7).GET /api/v1/stream— viewer-gated SSE endpoint: emits ahelloframe, 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 emitsapproval.approved/approval.rejected, so any open dashboard updates without polling. New publishers are onebus.publish()call at the mutation site. - Client (
frontend/src/lib/realtime.ts+useRealtime.ts) — nativeEventSourcecan't send anAuthorizationheader, so live surfaces consume/api/v1/streamvia 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()modelslive → reconnecting → polling. TheuseRealtime(channels, onEvent)hook wires these tofetch, returns the connection state, and reconnects with a fixed delay (escalating topollingso the surface falls back to REST, R5).<ConnectionBadge>renders that state viaStatusPill(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 overplatform.db:registry_rows()composes the union of models seen acrossdrift_snapshots,model_costs,traffic_rulesandpromotion_rulesinto ModelRow-shaped dicts (name × version × stage × health × freshness × governed). Health is a colour-blind-safe token (ok/warn/unknown, F3).promotion_check()mirrorsexa 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_REGISTRYis 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,StatusPillhealth, 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,EmptyStateon empty/error,_partialsurfaced 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— theFLAG_DEFSregistry (default+ optionaltenants/roles/percentagetargeting),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), andset_override()(persists tofeature_flag_overrides, audits toaudit_events, R4).routers/flags.py— viewerGET /api/v1/flags(decisions), adminGET /api/v1/flags/admin+POST /api/v1/flags/{name}(override → audit +event.flag_changedon the F8 bus for live kill-switch delivery, R2).- Client (
frontend/src/lib/serverflags.ts+pages/Flags.tsx) —useFlagDecisions()+ auseFlag(name)hook that prefers the server decision and falls back to the client default (flagFallback, pure/tested); an admin Flags page (useSetFlagmutation) to toggle flags. The F23lib/flags.tsremains 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.py—active_alerts()merges the three sources into severity-sorted, typed alert rows with per-severity counts (R1); each has a deterministicid(source:key). Drift alerts parse the baseline stats JSON and fire on a z-score threshold.acknowledge()audits the ack toaudit_events(R3/D4).routers/alerts.py— viewer-gatedGET /api/v1/alerts(BFF-composed) andPOST /api/v1/alerts/{id}/ack(audits + publishesalert.ackedon the realtime bus).- Client (
frontend/src/lib/alerts.ts+pages/Alerts.tsx) — the alert inbox with severity pills (pureseverityToken/severityRank/inboxHeadline) and an Ack button (useAckAlertmutation, invalidates the inbox). New Alerts nav + lazy/alertsroute. - 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_contextinjects 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_proposalspullsexa …commands out of the answer and flags eachrequiresApprovalwhen it names a mutating subcommand (retrain/promote/approve/traffic/…) — so the UI forces human confirmation + the approval flow (R5).ask_copilotcalls 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-gatedPOST /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 (F18useFocusTrap). Answers render throughsanitizeMarkdown(F16); proposals are copy-only cards with an approval-gate badge (no run button); the agent trace is collapsible (R6).lib/copilot.tsderives 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.py—endpoints()lists the LLM endpoint registry;eval_summary()returns the newest eval run per model with its metric rows (value / baseline / passed) and a computedpassRate. 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 +passRatepill via pureevalTone/passRateLabel). A footnote names the not-yet-available surfaces. New LLMOps nav + lazy/llmopsroute. - 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-approvalsatisfiedonly 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 theheadDigest— 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/gapStatusPills + evidence), an EU-AI-Act table, model-card coverage, and an audit-integrity badge with the short head digest. PurepostureToken/coverageLabel/digestShortunit-tested. Admin-only Governance nav +/governanceroute. - 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-localMetricscollector fed byMetricsMiddleware(counts requests, 4xx/5xx, 429 rate-limit hits, and a bounded p50/p95 latency window);dependency_health()probesplatform.dbreachability + latency for the status page (R5);record_ui_action()audits a UI action toaudit_events(R4 / D4), degrading quietly if the table is absent.routers/selfobs.py— viewer-gatedGET /api/v1/selfobs/status(health + metrics) andPOST /api/v1/selfobs/action(UI audit).frontend/src/lib/telemetry.ts—scrubPii()(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), anduseSelfObsStatus().pages/SelfObs.tsxis 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 amethodologystring +uncertaintyfraction (±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-budgetStatusPill, and the carbon methodology footnote. Pureusd/budgetPct/carbonLabelare unit-tested. New FinOps sidebar entry + lazy/finopsroute (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— arole → capability setcatalogue (viewer =view+search; admin addsmodel.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_CAPABILITIESflags 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_claimsdefaults a missing tenant claim to"default"(single-tenant case).GET /api/auth/menow returnstenant+capabilities[], so the UI reflects exactly what the backend grants.frontend/src/lib/capabilities.ts—useCapabilities()(from/me) + purecan/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.ts—ApiError(typedstatus+ parsedProblem) andparseProblem()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.apiFetchthrowsApiError; theQueryClientusesshouldRetry.components/ErrorBoundary.tsx— a class error boundary rendering a designedEmptyStatefallback 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:oklchLuminanceconverts OKLCH → OKLab → linear sRGB → relative luminance, sooklchContrast/meetsAAverify 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).tabbableWithinlists 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-hiddenaria-liveregion (polite + assertive) mounted at the app root;announce()clears-then-sets so repeat strings re-announce without stealing focus (R3). ARouteAnnouncerannounces the page on navigation; live F8 pushes can announce through the same hook. (Hook lives inhooks/announcer.tsso 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.translatedoes dot-path lookup with{{var}}interpolation and EN fallback + missing-key warn (R1 observability);translatePluralselects_one/_otherviaIntl.PluralRules(correct plurals per locale). All number/date/percent/relative formatting goes throughIntl(R3/GWT-2).formatHpcUnitis 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+utcTooltipgive user-TZ display with a UTC reference (R4).I18nProvider— holds the active locale (detected from the browser, persisted tolocalStorage) and keeps<html lang>/dirin sync so layout is RTL-safe via F3 logical properties (R5). The hook lives ini18nContext.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 viaformatHpcUnit, headings viat()).- 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.ts—matchBreakpointclassifies a width into laptop/desktop/wide/ultrawide (R1);useBreakpoint/useMediaQueryare jsdom/SSR-safe (seed in the initializer, subscribe in the effect — no set-state-in-effect);useRotatorauto-advances a wrap-around index for the NOC carousel (R2);popOutdetaches 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 purebuildNocSlides) 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 printstylesheet hides.no-printchrome (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,pushRecentInListdedup+cap) are unit-tested;usePrefs/useWatchlist/useOnboardingread-modify-write the whole store so slices never clobber each other.usePrefspersists the default landing page + density (R3);useWatchlistpins entities (R4);useOnboardinggates 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 honoursdefaultLandingso 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— pureextract_mentions/sanitize_comment(strip HTML +javascript:, R6) are unit-tested.add_commentsanitizes, extracts mentions, persists tenant-scoped, and audits (R1/R5);list_commentsis tenant-scoped (R1/F15);entity_activitymerges comments with the entity's audit events into one trail (R5/GWT-5).create_snapshotmints asecrets.token_urlsafeid with an expiry and stores the frozen view;get_snapshotreturns it read-only and only while unexpired (R2/GWT-3). The dashboard owns two additive tables — it never alters the CLI'splatform_dbschema.routers/collab.py— viewer-gated; tenant + author come fromprincipal_from_claims(F15). @-mentions publishevent.mentionon the F8 bus (F12/GWT-1). There is no cross-tenant read and no write path on a snapshot token.- Frontend —
CommentThread(adopted on ModelDetail) renders comments throughsanitizeMarkdown(F16/GWT-6) and previews who a draft will notify;ShareSnapshotButtoncreates 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.py—SecurityHeadersMiddlewareattaches the static header set (build_cspscopesframe-src/frame-ancestorsto the Grafana embed origin so F5 panels load while nothing else can iframe the dashboard, R1).RateLimiteris an in-process fixed-window limiter; therate_limit(limiter)dependency returns 429 past the limit (R7). Registered inmain.py; the search endpoint (fan-out per keystroke) is the first guarded route.frontend/src/lib/sanitize.ts—sanitizeMarkdown()strips<script>/<style>/framing tags, inlineon*=handlers, andjavascript:/data:text/htmlURIs before rendering;safeUrl()guards hrefs (R2).react-markdownalready renders raw HTML inert (norehype-raw), so this is a belt-and-suspenders layer — wired into the Docs and Model README renderers. Introducing unsanitizeddangerouslySetInnerHTMLis 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 (withhigher-worse/lower-worsedirection);histogram()bins a series;sparklinePoints()fits a series to an SVG polyline;ciLabel()/formatDelta()format uncertainty/deltas.<ChartFrame>— the required wrapper (F4 R7): anaria-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):
@xyflowlineage/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 (ListQuery→ListResult) 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).applySortis a stable multi-column sort;computeFacetscounts facet values over the filtered rows (R3);toCsvRFC-4180-escapes cells (R6);encodeQuery/decodeQueryround-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 tolocalStorage(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). AColumnmay supply an optionalrenderfor display whileaccessorstays 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— onesearch()federates over sources, reusing the F9/F6 read layers (mlops.registry_rows, thehpc_jobs/audit_eventstables) 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 entityurl, and are ranked by a dependency-freescore()(exact > prefix > word-boundary > substring > fuzzy subsequence). Missing tables degrade to no results for that source, never an error.routers/search.py—GET /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 theirexaequivalent for copy-as-CLI (F2 R6).lib/search.ts— a client mirror ofscore()(fuzzyScore) so palette command ranking matches server ranking;rankCommands(); and the debounced, cancelableuseSearch()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 inLayout.
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 thehpc_jobstable: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_secondsproxies priority/backfill since the table has no fair-share column, F6 R2).job_detail()carries resources, timing, exit code, and themlflow_run_idcost 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_jobstable / 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. PurewaitLabel()/partitionTone()are unit-tested. Skeleton while loading,EmptyStateon 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 typedGRAFANA_PANELSregistry (UIDs/panel-ids never hardcoded in components, R2) and the purebuildPanelUrl()(unit-tested — it's the security-sensitive part). Returnsnullwhen no base URL is configured so the component degrades instead of showing a broken frame.<GrafanaPanel>— builds thed-soloiframe 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). |