Strategic UI priorities and UX intent live in ROADMAP.md; this document is the technical reference (routes, components, build flags).
flightdeck serve serves the React app at /. It is built from web/ and the committed
production bundle lives under src/flightdeck/server/static/.
For setup, dev workflow, build commands, and Playwright E2E instructions see
web/README.md.
The README product overview image is a marketing composite: dark chrome, dense “dashboard” cards, and narrative labels that do not map one-to-one to shipped pages. The bundled hex mark (web/public/flightdeck-icon.png) matches that art direction (cyan–purple accent, dark ground). This document and the operator UI stay grounded in real /v1/* data—visual work should not invent panels (for example a synthetic “release blocked” hero) until the APIs and product decisions exist.
| Art direction | Application in this repo |
|---|---|
| Dark navy / near-black shell | html[data-theme="dark"] in web/src/index.css mirrors semantic tokens; Appearance control in the sidebar defaults to Light (stored under localStorage key flightdeck-theme). |
| Cyan → purple gradient | CSS variables (for example --fd-accent-gradient) for active nav, primary buttons, and focus-visible accents—used sparingly so trust/safety UI stays calm. |
| High-contrast titles | Tune --fd-type-* and weights under dark mode; avoid shrinking body text for density. |
| “Neon” feel | Reserve for interactive states, not large background fills. |
| Geometric sans | Shipped: offline system UI stack in index.css (--fd-font). Optional: install Inter locally if you want that face without bundling remote CSS. |
- Token foundation — Extend
:rootwith any missing semantics (--fd-surface-elevated, gradient stops, optional--fd-bg-subtle). Replace scattered literals inweb/src/index.css(for example warning callout backgrounds) with variables so dark mode does not require hunting hex values. [data-theme="dark"]block — Mirror every semantic token used by.fd-shell, sidebar, cards, tables,Badge, drawers, andJsonPanel; setcolor-scheme: darkonhtmlwhen active. Validate WCAG AA for body text and links.- Preference UI —
/#/settings(and room for more prefs later): Light / Dark / System; listen toprefers-color-schemewhen System is selected. PersistlocalStoragekeyflightdeck-theme(light|dark|system). - Brand accents — Apply the gradient token to active
.fd-nav__link--active(left rail) and primary submit-style buttons; keep destructive actions on existing red semantics. - Light theme polish — Even before dark ships: align spacing rhythm and card shadows with the same tokens so both themes stay maintainable.
- Verification — From
web/:npm ci,npm run build, commitsrc/flightdeck/server/static/;npm run test:e2e(includese2e/theme.spec.ts: default light, dark persistence, system /prefers-color-scheme, overview smoke in dark). Manually smoke Diff and Actions in both themes (policy panels, JSON drawer, rollback affordances).
- Multi-theme marketplaces, per-user arbitrary color pickers, or third-party skin systems — off mission.
- Infographic-only widgets (staged DEV→STAGING→PROD pipeline strip, sparkline grids) — wait for real APIs and ROADMAP operator outcomes, not decorative parity with the poster.
The app uses HashRouter (react-router-dom) so all navigation stays within the single
index.html that FastAPI's static file mount serves. URLs look like
http://127.0.0.1:8765/#/diff. No server-side route matching is required.
Static UI assets: hashed bundles are mounted at /assets/. The sidebar mark and tab icons use the bundled URL from web/src/assets/flightdeck-icon.png (emitted as /assets/flightdeck-icon-<hash>.png; main.tsx sets <link rel="icon"> at runtime). A stable duplicate remains at GET /flightdeck-icon.png (from web/public/ at build time + FastAPI FileResponse) for bookmarks, probes, and web/e2e/smoke.spec.ts.
Typography: the UI uses an offline-first system font stack (no Google Fonts or other remote CSS). Install Inter locally if you want that face in dev tools without changing the bundle.
| Hash path | Component | HTTP calls | Notes |
|---|---|---|---|
#/ |
OverviewPage |
GET /v1/releases, GET /v1/promoted, GET /v1/actions, GET /v1/metrics (parallel where applicable) |
Ledger metrics (read-only); short per-counter hints; skeleton on first load; auto-refresh every 30s when the tab is visible + on timeline generation bump; links to Diff/Runs |
#/diff |
DiffPage |
POST /v1/diff |
Sections: policy gate (incl. evaluated_at), evidence window, pricing/catalog/hints (incl. provider/version skew callout when sides differ), per-1k prices when present, cost/quality rollups; raw JSON panel |
#/runs |
RunsPage |
GET /v1/releases (for datalist), GET /v1/runs, GET /v1/runs/export |
Forensics: filters, table (trace/status, trace band rows or Group by trace_id), View drawer (focus trap, session/span ids), typed run-query error card with Retry, empty/offset/truncation hints, NDJSON download |
#/settings |
SettingsPage |
(none) | Color theme (Light / Dark / System) via ThemeToggle; more preferences later. |
#/actions |
ActionsPage |
GET /v1/workspace, GET /v1/promotion-requests (when promotion_requires_approval), POST /v1/promote or POST /v1/promote/request + POST /v1/promote/confirm, POST /v1/rollback |
Workspace skeleton then strip; approval path: numbered steps, pending Refresh list / Use for confirm; Rollback danger-styled; see ActionsPage below |
#/* (any other) |
— | Redirects to #/ |
App.tsx declares the route tree. AppShell is the layout wrapper rendered for all routes.
When VITE_FLIGHTDECK_UI_READ_ONLY=true is set at build time, the #/actions route
renders a <Navigate to="/" replace /> rather than ActionsPage, and the nav link for
Promote is suppressed. The read-only mode is for demos and shared screens where
promote/rollback capability should be unavailable regardless of network placement.
ThemePreferenceProvider (`App.tsx`)
└── HashRouter
└── Routes / AppShell layout route
└── TimelineRefreshProvider
└── div.fd-shell
├── aside.fd-sidebar (brand, collapse chevron, primary nav, footer nav → Settings)
└── div.fd-shell__content
├── SecurityStatusBar
└── main#main-content → OverviewPage | DiffPage | RunsPage | ActionsPage | SettingsPage
Renders a fixed-width left sidebar (aside.fd-sidebar) with brand (gradient FlightDeck wordmark, mark in a raised tile), a collapse control (SVG chevrons, localStorage flightdeck-sidebar-collapsed), a primary nav (inline SVG icons + labels; icon-only when collapsed), and a footer nav pinned to the bottom of the rail with Settings → #/settings. Then a fd-shell__content column with SecurityStatusBar and
<main> wrapping an <Outlet> for the active page. On narrow viewports the sidebar stacks
above the content with a horizontal nav row; a collapsed rail is expanded back to full labels in that breakpoint. Wraps the subtree in TimelineRefreshProvider
so any descendant can access the refresh context. ThemePreferenceProvider (from App.tsx) wraps the router so ThemeToggle on Settings can read and update flightdeck-theme; main.tsx applies the effective theme before the first paint to avoid a flash of the wrong scheme.
A Skip to main content link (class fd-skip-link) appears first in the shell; it uses
preventDefault + focus() on #main-content so HashRouter hash URLs (#/…) are not
replaced by a fragment-only href.
Nav links use NavLink from react-router-dom with an fd-nav__link--active class applied
when the route is active. The Promote nav link is suppressed when UI_READ_ONLY is
true (see uiConfig.ts below).
Provides a lightweight cross-page coordination signal:
| Export | Description |
|---|---|
TimelineRefreshProvider |
Wrap the app (or a subtree) to enable the context |
useTimelineRefresh() |
Returns { generation, notifyTimelineMutated } |
generation — a monotonically incrementing integer. OverviewPage declares it as a
useEffect dependency, so every increment triggers a fresh loadTimeline() fetch.
notifyTimelineMutated() — call this after any successful promote or rollback. It
increments generation via setGeneration(g => g + 1) and is memoized with useCallback
so it is stable across renders.
Throws if called outside TimelineRefreshProvider.
Data flow for a successful direct promote (when promotion_requires_approval is false in flightdeck.yaml):
- User fills the
ActionsPageform and clicks Promote. ActionsPagecallsfetchJson→POST /v1/promote.- On success,
notifyTimelineMutated()is called. OverviewPage(mounted in the same shell) seesgenerationchange via context.useEffectfiresloadTimeline()and re-renders tables with fresh data.
When promotion_requires_approval is true, step 2 uses POST /v1/promote/request instead; confirm uses
POST /v1/promote/confirm from the same page. GET /v1/workspace on mount drives which buttons are shown.
Build-time configuration helpers read from import.meta.env:
| Export | Type | Description |
|---|---|---|
UI_READ_ONLY |
boolean |
true when VITE_FLIGHTDECK_UI_READ_ONLY === "true". Hides the Promote nav link, redirects #/actions to #/, and causes SecurityStatusBar to show a read-only banner instead of the auth status. |
clientMutationTokenConfigured() |
() => boolean |
Returns true when VITE_FLIGHTDECK_LOCAL_API_TOKEN is set to a non-empty, non-whitespace string in the build env. Used by SecurityStatusBar to detect a mismatch between the server's bearer requirement and the client's token configuration. |
Mounted by AppShell at the top of the main content column (below the sidebar on wide
layouts). Fetches GET /health
on mount to read mutation_auth and read_auth ("bearer" / "loopback" and "bearer" / "open"), then renders an info or
warning strip:
| Condition | What is shown |
|---|---|
UI_READ_ONLY=true |
Info banner: "Read-only UI: navigation to promote and rollback is disabled." |
/health in flight |
Muted line + skeleton; aria-busy="true" on the strip |
/health fetch failed |
Warning banner: "Could not load server security mode." (with error detail) |
mutation_auth === null (unknown value) |
Nothing (renders null) |
Server "bearer" + client has no token |
Warning: token mismatch — promote/rollback will be rejected until the UI token matches the server |
| Normal (no mismatch) | Info strip with two lines: server mode description and client token status |
The component never displays the token value itself. It uses the role="status" ARIA role
for live-region accessibility.
Token mismatch detection: when the server uses a Bearer token (mutation_auth is "bearer") and
clientMutationTokenConfigured() is false, the strip warns that API requests will
fail. This is a configuration hint only — the server enforces the actual gate.
Read-only dashboard. Renders a Ledger metrics card from fetchMetrics() plus three tables from loadTimeline() output:
| Block | Source | Content |
|---|---|---|
| Ledger metrics | GET /v1/metrics |
Releases, pricing tables, run events, promoted pointers, and actions totals (plus actions_by_action breakdown), schema_version, generated_at |
| Releases | GET /v1/releases |
Release ID, Agent, Version, Environment, Checksum, Created |
| Promoted | GET /v1/promoted |
Agent, Environment, Active release |
| Recent actions | GET /v1/actions |
When, Action, Policy (PASS/FAIL badge), Release, Environment, Reason |
Long IDs are abbreviated with shortId(id, keepStart, keepEnd) and shown in full on hover
via the HTML title attribute.
Refresh: while the document tab is visible, the page auto-polls metrics and the
timeline on an interval and uses silent fetches after the first load. The generation
counter from TimelineRefreshContext triggers an immediate refresh after mutations from
ActionsPage.
Form-based interface for POST /v1/diff. Fields mirror the request body:
| Field | Default | Maps to |
|---|---|---|
| Baseline release ID | (empty) | baseline_release_id |
| Candidate release ID | (empty) | candidate_release_id |
| Window | 7d |
window |
| Environment | local |
environment (sent as null when empty) |
tenant_id and task_id are not exposed in the UI form. To run a diff narrowed to a
specific tenant or task, use the CLI (flightdeck release diff --tenant <id> --task <id>)
or call POST /v1/diff directly with the tenant_id and task_id fields. See
http-api.md § POST /v1/diff and
operations-and-policy.md § compute_diff vs. promote_release filter scope
for details on what those filters affect.
On submit, the raw diff response is parsed and rendered as:
- Summary card: policy badge (PASS / FAIL), failure reasons list, sample counts and
confidence label (including
confidence_reasonwhen present). - Pricing table warnings: when
pricing.warningsis a non-empty string array, afd-alert--warnlist is shown above the pricing/model-change banner (diagnostic only). - Catalog / hints: when
pricing.catalogorpricing.hintsis present, the UI surfaces catalog enabled state, lines, and hint strings (see pricing-catalog.md). - Pricing change warning: when the diff response includes a
pricingblock withpricing_or_model_changed: true, afd-alert--warnbanner is shown in the summary card. It names the baseline and candidate provider/version/model so the user knows the cost delta includes pricing assumption changes, not just usage changes. When the response also includes apricing.pricesblock with all four per-1k token rates present, the banner additionally shows a Per-1k token prices line (baseline → candidate, input and output separately) so the user can separate tariff moves from token volume changes in the cost delta. Rates are rendered to six decimal places viatoFixed(6). - Metric cards: cost/run (USD), latency avg (ms), error rate — each showing baseline, candidate, and delta.
- Raw diff JSON panel (collapsed by default via
JsonPanel).
The Compute diff button is disabled while the request is in flight (busy state).
Errors from the API are shown as an inline fd-alert--error element.
Note: POST /v1/diff is a read-only computation and does not require a mutation
token. See http-api.md for the full response schema.
On mount, loads GET /v1/workspace (fetchWorkspace) and renders a short status strip:
server_version, whether a pricing_catalog_path is configured (pricing_catalog_configured), and
whether promotion_requires_approval is on.
Mutation form fields:
| Field | Default | Maps to |
|---|---|---|
| Release ID | (empty) | release_id |
| Environment | local |
environment |
| Window | 7d |
window |
| Reason (required) | (empty) | reason (promote, rollback, and promotion request) |
The actor field is hardcoded to "react-ui" for audit log attribution.
Direct promotion (default): Promote calls POST /v1/promote. Rollback always calls POST /v1/rollback.
Approval mode: Promote is replaced by Request promotion (POST /v1/promote/request). A Pending promotion requests
table refreshes from GET /v1/promotion-requests?status=pending. Confirm promotion posts POST /v1/promote/confirm with
request_id (prefilled after a successful request) and approval_reason. A collapsible Promotion request (raw JSON)
panel shows the request response.
All primary actions use window.confirm. Buttons are disabled while a request is in flight (busy state). Empty
reason / empty confirm fields abort with inline errors.
After a successful promote or rollback (or confirm):
- The response is parsed by
pickOutcome()when it matches the promote/rollback outcome contract; otherwise the raw JSON panel opens. - Outcome card shows policy badge, pointer badge, metric grid, and reasons list when applicable.
notifyTimelineMutated()runs soOverviewPagerefetches.
Auth: VITE_FLIGHTDECK_LOCAL_API_TOKEN is sent on every fetchJson call, including POST /v1/promote/request and POST /v1/promote/confirm. It must match FLIGHTDECK_LOCAL_API_TOKEN on the server when the server enforces Bearer (see http-api.md § Authentication). FlightDeck does not mint this value: the operator chooses a shared secret for HTTP API access. It is baked in at build time for the committed static bundle; local Bearer testing normally uses web/.env.local + npm run dev. It is not OAuth or end-user SSO — see SECURITY.md (Local HTTP API).
HTTP errors: fetchJson formats FastAPI detail strings, validation arrays, and { message: … } objects into a single Error message for the alert line.
Typed client helpers shared across pages.
type ReleaseRow = {
release_id: string; agent_id: string; version: string;
environment: string; checksum: string; created_at: string;
};
type PromotedRow = { agent_id: string; environment: string; release_id: string; };
type ActionRow = {
action_id: string; action: string; release_id: string; agent_id: string;
environment: string; baseline_release_id: string | null; reason: string;
policy_passed: boolean; policy_reasons: string[];
created_at: string; audit_seq: number | null;
};
type TimelinePayload = { releases: ReleaseRow[]; promoted: PromotedRow[]; actions: ActionRow[]; };
type HealthPayload = {
status: string;
/** Present on current servers; "bearer" when FLIGHTDECK_LOCAL_API_TOKEN is set. */
mutation_auth?: "bearer" | "loopback";
read_auth?: "bearer" | "open";
};
/** Mirrors the `policy` sub-object in promote/rollback responses and diff responses. */
type PolicyResultPayload = {
passed: boolean;
reasons: string[];
evaluated_at?: string;
};
/**
* Full HTTP 200 body for `POST /v1/promote` and `POST /v1/rollback`.
* Mirrors `_action_body()` in `src/flightdeck/server/routes/actions.py`.
*
* On HTTP 409 (policy blocked), the server wraps an equivalent object inside
* `{ detail: { message, outcome } }` where `outcome` has the same shape.
*/
type ActionOutcomePayload = {
action_id: string;
action: "promote" | "rollback";
release_id: string;
agent_id: string;
environment: string;
baseline_release_id: string | null; // null on first promotion
promoted_pointer_changed: boolean;
policy: PolicyResultPayload;
};Thin wrapper around fetch:
- Reads
VITE_FLIGHTDECK_LOCAL_API_TOKENfromimport.meta.envand injects anAuthorization: Bearer …header if the env var is non-empty and noAuthorizationheader is already set. - Calls
fetch(path, { ...init, headers }). - On non-2xx, formats
response.json().detail(string, validation array, or object withmessage) into a readable message and throwsError(…). - On JSON parse failure, falls back to
{}before checkingres.ok.
Calls fetchJson<HealthPayload>("/health"). Used by SecurityStatusBar to discover the
server's mutation-auth mode ("bearer" or "loopback") without exposing secret values.
Fires three GET requests in parallel via Promise.all:
GET /v1/releases→{ releases }GET /v1/promoted→{ promoted }GET /v1/actions→{ actions }
Returns a merged TimelinePayload. Used by OverviewPage on mount and on every
generation increment.
Calls GET /v1/workspace. Used by ActionsPage on mount.
Calls GET /v1/promotion-requests with optional query parameters. Used by ActionsPage when
promotion_requires_approval is true to populate the pending requests table.
<Badge tone="pass">PASS</Badge>
<Badge tone="fail">FAIL</Badge>
<Badge tone="neutral">—</Badge>Renders a <span> with the appropriate fd-badge--{tone} class. Used in action tables and
diff summary.
Collapsible raw-JSON viewer. Props:
| Prop | Type | Default | Description |
|---|---|---|---|
title |
string | "Raw JSON" |
Button label |
value |
string | — | JSON string to display in a <pre> |
defaultOpen |
boolean | false |
Whether expanded on first render |
Uses aria-expanded and aria-controls for accessibility. Toggle state is local (useState).
All tokens are CSS custom properties on :root:
| Token | Purpose |
|---|---|
--fd-bg |
Main column page background |
--fd-surface |
Card / sidebar rail background |
--fd-sidebar-width |
Width of the left navigation rail (wide layouts) |
--fd-surface-2 |
Secondary surface (hover, code blocks) |
--fd-border |
Standard border |
--fd-border-strong |
Input and button borders |
--fd-text |
Primary text |
--fd-muted |
Secondary / label text |
--fd-accent |
Primary action color (buttons, links) |
--fd-accent-hover |
Hover state for accent |
--fd-pass-bg / --fd-pass-fg |
PASS badge colors |
--fd-fail-bg / --fd-fail-fg |
FAIL badge colors |
--fd-radius |
Standard border radius |
--fd-radius-sm |
Small border radius |
--fd-shadow |
Card box shadow |
--fd-font |
System sans-serif stack |
--fd-mono |
Monospace stack |
| Class | Description |
|---|---|
fd-shell |
Full-height row: sidebar + main column |
fd-sidebar |
Left rail: brand block + fd-sidebar__nav primary links |
fd-shell__content |
Flex column: security strip + fd-main |
fd-nav__link |
Sidebar nav link; --active modifier (accent left border) |
fd-main |
Page content area with max-width and padding |
fd-page-head |
Flex row with title/subtitle and optional action button |
fd-card |
White surface card with border and shadow |
fd-card__head |
Card header row (title + optional inline elements) |
fd-table |
Full-width table with hover rows |
fd-table-wrap |
Horizontally scrollable table container |
fd-badge |
Inline status chip; --pass, --fail, --neutral modifiers |
fd-btn |
Base button; --primary (accent fill), --ghost (borderless) |
fd-form-grid |
CSS Grid layout for form fields |
fd-field |
Label + input pair; --full modifier spans both grid columns |
fd-input |
Styled text input |
fd-alert |
Inline alert box; --error, --info, --warn modifiers |
fd-security-strip |
Strip at top of main column; wraps SecurityStatusBar output |
fd-security-strip__msg |
Message paragraph inside the security strip (zero margin) |
fd-json-panel |
Collapsible JSON viewer container |
fd-metric-grid |
Grid of metric cards for diff output |
fd-metric |
Single metric card (label, baseline → candidate, delta) |
fd-mono |
Monospace inline span; --sm for smaller size |
fd-muted |
Secondary text color |
fd-nowrap |
white-space: nowrap for date/ID cells |
fd-empty-cell |
Centered empty-state message in a table cell |
fd-inline |
Inline flex row used for label + badge pairs inside card headers |
fd-samples |
Muted paragraph for sample/confidence metadata in diff and action outcome cards |
fd-reasons |
Small bulleted list of policy failure reasons; used in DiffPage and ActionsPage outcome cards |
| Variable | Where set | Effect |
|---|---|---|
VITE_FLIGHTDECK_LOCAL_API_TOKEN |
.env.local or build env |
Injected as Authorization: Bearer … on every fetchJson call. Must match FLIGHTDECK_LOCAL_API_TOKEN on the server when the server token gate is active. |
VITE_FLIGHTDECK_UI_READ_ONLY |
.env.local or build env |
Set to "true" to enable read-only mode: hides the Promote nav link, redirects #/actions to #/, and shows a read-only banner in SecurityStatusBar. Intended for demo / shared-screen deployments. |
VITE_DEV_PROXY_TARGET |
.env.local |
Overrides the Vite dev proxy target (default: http://127.0.0.1:8765) |
These are build-time variables (import.meta.env). They are baked into the JavaScript
bundle at build time; the production static/ bundle does not read them from the server at
runtime.
Note on whitespace-only tokens: VITE_FLIGHTDECK_LOCAL_API_TOKEN is treated as
unset (and no Authorization header is sent) when the value is empty or whitespace only.
The server applies the same whitespace trim to FLIGHTDECK_LOCAL_API_TOKEN — a
whitespace-only server token is treated as no token (mutation_auth: "loopback").
- Create
web/src/pages/MyPage.tsxwith a named exportMyPage. - Add a route in
web/src/App.tsx:<Route path="my-page" element={<MyPage />} />
- Add a
NavLinkinweb/src/components/AppShell.tsx. - Use
fetchJsonfrom../apifor HTTP calls anduseTimelineRefreshif the page performs mutations. - Rebuild:
npm run buildfromweb/, then verifygit diff --exit-code src/flightdeck/server/static/is clean and commit.