From bff152e17514a57c384f3f243c22b1e196286960 Mon Sep 17 00:00:00 2001 From: zendaya Date: Sun, 3 May 2026 01:33:37 +0200 Subject: [PATCH 1/2] feat: enhance web UI and documentation for improved user experience - Updated the OverviewPage to include auto-refresh functionality every 30 seconds when visible, enhancing real-time data visibility. - Improved the ActionsPage with clearer messaging regarding the configuration of the `VITE_FLIGHTDECK_LOCAL_API_TOKEN`, providing inline hints for users. - Enhanced the README and web UI documentation to reflect recent changes and improve discoverability of features. - Revised the ROADMAP.md to include a new backlog item for a visual system, clarifying future development directions. This update aims to streamline user interactions and provide clearer guidance on using the FlightDeck platform effectively. --- .github/workflows/ci.yml | 2 - CHANGELOG.md | 17 +- DEVELOPMENT.md | 5 +- ROADMAP.md | 3 +- docs/cli.md | 13 +- docs/operations-and-policy.md | 4 +- docs/release-artifact.md | 11 +- docs/web-ui.md | 53 ++-- examples/README.md | 2 + pyproject.toml | 3 + src/flightdeck/cli/main.py | 43 +-- src/flightdeck/models.py | 3 + src/flightdeck/server/app.py | 4 +- src/flightdeck/server/routes/common.py | 4 +- src/flightdeck/storage.py | 127 ++++++--- tests/test_doctor.py | 3 +- web/README.md | 2 +- web/e2e/actions-approval.spec.ts | 10 + web/playwright.config.ts | 13 +- web/scripts/e2e-server.mjs | 4 +- web/src/components/AppShell.tsx | 33 ++- web/src/components/SecurityStatusBar.tsx | 12 + web/src/index.css | 329 +++++++++++++++++++---- web/src/pages/ActionsPage.tsx | 44 ++- web/src/pages/OverviewPage.tsx | 32 ++- web/src/pages/RunsPage.tsx | 160 ++++++++++- 26 files changed, 741 insertions(+), 195 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 015b060..d704393 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,6 @@ jobs: - name: Playwright E2E (approval workspace) env: FD_E2E_FORCE_APPROVAL: "1" - PW_WEBSERVER_APPROVAL: "1" run: | cd web npx playwright install chromium @@ -127,7 +126,6 @@ jobs: shell: bash env: FD_E2E_FORCE_APPROVAL: "1" - PW_WEBSERVER_APPROVAL: "1" run: | cd web npx playwright install chromium diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4c8e2..c4c4733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,19 +8,16 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ### Changed -- **Web Runs:** forensics UX — empty / offset / truncation messaging, export copy aligned to server limits, trace band rows, **View** drawer with structured fields and full event JSON, extra table columns (trace, status). -- **Web Diff:** scannable sections (policy, evidence window, pricing/catalog/hints, rollups), pre-query hint, `evaluated_at` when present; **examples** index and **integration** README link **`/#/diff`** and **`POST /v1/diff`** to the end-to-end loop. -- **Web Actions:** workspace loading skeleton; numbered steps when approval is on; pending table **Use for confirm** and **Refresh list**; clearer browser confirm copy and approval-reason placeholder. -- **Web shell / Overview:** skeleton loading instead of plain “Loading…”; **Refresh** disabled while loading; ledger metrics line with links to **Diff** and **Runs**; per-metric hint lines; Diff query card **`aria-busy`** while computing. -- **Web Runs:** optional **Group by trace_id** (collapsible `
` per trace); **View** uses **`aria-haspopup="dialog"`**. -- **Web Diff:** warn when imported **pricing table versions** or **providers** differ between baseline and candidate (same `pricing` block as before). -- **Web security strip:** loading state with **`aria-busy`** while **`/health`** is fetched. -- **Web Actions:** **Rollback** uses danger-styled button (still same confirm + API). -- **Examples / deploy / SECURITY:** [examples/README.md](examples/README.md) step 7 and readiness row mention **`/#/runs`** grouping; [examples/deploy/README.md](examples/deploy/README.md) operator checklist; Compose **`restart: unless-stopped`** on the reference service; **[SECURITY.md](SECURITY.md)** links the deploy guide for operational hardening. -- **Playwright:** `e2e-server.mjs` enables **`promotion_requires_approval`** only when **`PW_FORCE_APPROVAL_WORKSPACE=1`** (set from the CLI target or **`PW_WEBSERVER_APPROVAL`**); default suite no longer breaks on a stray **`FD_E2E_FORCE_APPROVAL`** shell export; **`reuseExistingServer: false`** for a clean workspace each run; **[web/README.md](web/README.md)** documents approval vs default runs. +- **Web Runs:** forensics — empty / offset / truncation messaging, export copy, trace band rows or **Group by trace_id**, **View** drawer (structured fields + full JSON, **session_id** / **span_id**, focus trap + return focus, **`aria-haspopup="dialog"`**), trace/status columns; **run-query** failures show a typed error card with **Retry**. +- **Web Diff:** scannable sections (policy, evidence window, pricing/catalog/hints, rollups), pre-query hint, `evaluated_at` when present; warn when imported **pricing table versions** or **providers** differ baseline vs candidate. +- **Web Actions:** workspace loading skeleton; numbered approval steps; pending **Refresh list** / **Use for confirm**; clearer confirms; approval-reason placeholder; **Rollback** danger-styled; **Actions** shows whether **`VITE_FLIGHTDECK_LOCAL_API_TOKEN`** is set (no value) and an inline hint when the server uses **Bearer** and the UI token is missing. +- **Web shell / Overview / CSS:** **Langfuse-style** left sidebar + main column (stacks on narrow viewports); skeleton loading on first load; **Overview** auto-polls timeline + metrics every **30s** when the tab is visible (silent refresh; no manual **Refresh** button); updates after **Actions** mutations via context; ledger metrics hints + links to **Diff** / **Runs**; Diff query **`aria-busy`**; **Security strip** `/health` loading + **Bearer** + client-token reassurance line; shared **focus-visible** / type scale / narrow breakpoints; **skip to main** (HashRouter-safe); **[ROADMAP.md](ROADMAP.md)** adds **Visual system** backlog item and theme deferral. +- **Examples / deploy / SECURITY / web README:** [examples/README.md](examples/README.md) end-to-end loop + **UI polish / operator flow** blurb; deploy checklist + **`restart: unless-stopped`**; **[SECURITY.md](SECURITY.md)** deploy pointer; **[web/README.md](web/README.md)** Playwright approval vs default runs. +- **Playwright:** `e2e-server.mjs` gates approval workspace on **`PW_FORCE_APPROVAL_WORKSPACE`** (set from config); **`reuseExistingServer: false`**; config sets approval workspace only when the CLI lists **exactly one** `e2e/*.spec.ts` path and it is **`actions-approval.spec.ts`** (avoids multi-spec argv; **`PW_WEBSERVER_APPROVAL`** no longer toggles the server so a stale value cannot break **`npm run test:e2e`**); **`actions-approval.spec.ts`** skips when **`GET /v1/workspace`** shows approval off (e.g. full suite with **`FD_E2E_FORCE_APPROVAL=1`**). ### Added +- **PostgreSQL ledger:** optional **`database_url`** in **`flightdeck.yaml`** (`postgresql://` or `postgres://`); install **`psycopg`** with **`uv sync --extra postgres`** (or **`pip install 'flightdeck-ai[postgres]'`**). Same schema migrations and API behavior as SQLite; run filters use **`::json`** predicates on **`event_json`**. **`flightdeck doctor --backup`** stays SQLite-only (use **`pg_dump`** for Postgres). Optional integration tests: **`FLIGHTDECK_TEST_POSTGRES_URL`** with the **`postgres`** extra. - **`GET /v1/runs/export`** — NDJSON stream of the same filtered slice as **`GET /v1/runs`** (optional response headers when truncated). - **`session_id`** / **`span_id`** query filters on **`GET /v1/runs`**, matching CLI/SDK, and **`offset`** pagination on run listings (with **`runs list`** / **`runs export`**). - **Web Runs** page — query **`GET /v1/runs`** from the bundled UI. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 191273c..6001138 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,7 +18,10 @@ uv sync --extra dev This creates **`.venv/`** (gitignored), installs **`flightdeck`** editable plus **pytest** and **ruff**, and pins versions from **`uv.lock`**. -Optional extras (telemetry, SDK helpers): e.g. **`uv sync --extra dev --extra telemetry`**. +Optional extras (telemetry, SDK helpers, PostgreSQL driver): e.g. +**`uv sync --extra dev --extra telemetry`** or **`uv sync --extra dev --extra postgres`** +for **`database_url`** / optional **`tests/test_storage_postgres.py`** runs (**`FLIGHTDECK_TEST_POSTGRES_URL`**). +Local driver test helper (Docker optional): **`scripts/run_postgres_tests.ps1`** (see script header). ### Package extras diff --git a/ROADMAP.md b/ROADMAP.md index 69b3961..6a2c47e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,10 +53,11 @@ These map to **What is next** items **1**, **2**, and **5**; ship notes stay in 4. **Overview and trust** — Metrics **context** (what a counter means), light cross-links to Diff/Runs—not a metrics dashboard product. 5. **Shell and quality bar** — **Loading** states, consistent spacing and type rhythm, keyboard **focus** and labels, layouts that tolerate narrow viewports where cheap. 6. **Security ergonomics (UI)** — Token/env/mutation visibility, read-only build behavior, cautious affordances for destructive actions. +7. **Visual system** — Shared typography scale, spacing rhythm, **focus-visible** affordances, and narrow-layout breakpoints so the operator surfaces stay legible without a separate design system product. **Explicit UI deferrals** -Out of scope for the near-term web app: theme marketplaces; embedded arbitrary log viewers; full observability or fleet consoles in the browser; multi-workspace UI (follows conditional **Fleet / cross-workspace** in **What is next**). +Out of scope for the near-term web app: custom themes or theme marketplaces; embedded arbitrary log viewers; full observability or fleet consoles in the browser; multi-workspace UI (follows conditional **Fleet / cross-workspace** in **What is next**). --- diff --git a/docs/cli.md b/docs/cli.md index 6365039..402f8be 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,21 +62,26 @@ Wrote flightdeck.yaml ``` The generated file uses all defaults. Edit `diff.*` thresholds or `db_path` before using -in a shared repo. See [release-artifact.md § Workspace config](release-artifact.md). +in a shared repo. For **PostgreSQL**, set **`database_url`** to a `postgresql://…` (or +`postgres://…`) DSN and install **`psycopg`** (`uv sync --extra postgres`); **`db_path`** +is ignored when **`database_url`** is set. **`flightdeck doctor --backup`** remains +SQLite-only. See [release-artifact.md § Workspace config](release-artifact.md). --- ## `flightdeck doctor` -Run read-only health checks on the local SQLite ledger. +Run read-only health checks on the workspace ledger (SQLite file or PostgreSQL when +**`database_url`** is configured). ```bash flightdeck doctor [--backup PATH] ``` Calls `Storage.migrate()` at start (idempotent). With **`--backup PATH`**, runs an SQLite -online backup of the workspace database to **`PATH`** (parent directories are created; -an existing file is overwritten), then runs the checks below. +online backup of the workspace database to **`PATH`** when the workspace uses SQLite +(**`--backup`** is rejected for PostgreSQL-ledgers; use **`pg_dump`** instead). Parent +directories are created; an existing file is overwritten, then the checks below run. Without **`--backup`**, only the checks run. In both cases **`migrate()`** runs first. diff --git a/docs/operations-and-policy.md b/docs/operations-and-policy.md index 94d8b03..e5697a3 100644 --- a/docs/operations-and-policy.md +++ b/docs/operations-and-policy.md @@ -39,8 +39,10 @@ maps these to `click.ClickException`; the HTTP layer maps them to HTTP 400. `server/app.py` registers a FastAPI **lifespan** handler that runs at startup: ```python +from flightdeck.storage import storage_from_config + cfg = load_config() # reads flightdeck.yaml from cwd -storage = Storage(cfg.db_path) +storage = storage_from_config(cfg) storage.migrate() app.state.cfg = cfg app.state.storage = storage diff --git a/docs/release-artifact.md b/docs/release-artifact.md index 5f330fe..149be81 100644 --- a/docs/release-artifact.md +++ b/docs/release-artifact.md @@ -142,7 +142,9 @@ look for this file in the current working directory. ```yaml api_version: v1 kind: WorkspaceConfig -db_path: .flightdeck/flightdeck.db # SQLite database path +db_path: .flightdeck/flightdeck.db # SQLite database path (default when database_url unset) +# Optional: ledger on PostgreSQL (requires psycopg; install flightdeck-ai[postgres]) +# database_url: postgresql://user:pass@localhost:5432/flightdeck default_environment: local # default environment for register/diff/promote diff: min_candidate_runs: 500 # HIGH confidence threshold (candidate side) @@ -154,8 +156,11 @@ diff: # promotion_requires_approval: false ``` -All fields have defaults; an empty `flightdeck.yaml` is valid. `db_path` accepts any -relative or absolute path — the parent directory is created automatically on first use. +All fields have defaults; an empty `flightdeck.yaml` is valid. **`db_path`** accepts any +relative or absolute SQLite path — the parent directory is created automatically on first use. +When **`database_url`** is a `postgresql://` (or `postgres://`) DSN, the ledger uses that +database instead and **`db_path`** is ignored for storage (keep **`flightdeck doctor --backup`** +on SQLite, or use **`pg_dump`** for Postgres). **`pricing_catalog_path`** — optional path to a [`PricingCatalog`](../schemas/v1/pricing_catalog.schema.json) YAML (relative to the workspace cwd or absolute). When set, diffs include additive `pricing.catalog` / `pricing.hints`. diff --git a/docs/web-ui.md b/docs/web-ui.md index 6d7e971..0f940b4 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -18,9 +18,9 @@ The app uses **HashRouter** (`react-router-dom`) so all navigation stays within | 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 while loading; links to Diff/Runs | +| `#/` | `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, empty/offset/truncation hints, NDJSON download | +| `#/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 | | `#/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 `#/` | | @@ -37,23 +37,28 @@ promote/rollback capability should be unavailable regardless of network placemen ``` App (HashRouter) -└── AppShell (layout: header + nav) +└── AppShell (layout: left sidebar + main column) └── TimelineRefreshProvider (context) - ├── SecurityStatusBar (below header, above main content) - ├── OverviewPage (route: #/) - ├── DiffPage (route: #/diff) - ├── RunsPage (route: #/runs) - └── ActionsPage (route: #/actions; redirects → #/ when UI_READ_ONLY) + └── div.fd-shell + ├── aside.fd-sidebar (brand + primary nav) + └── div.fd-shell__content + ├── SecurityStatusBar + └── main#main-content → OverviewPage | DiffPage | RunsPage | ActionsPage ``` --- ## `AppShell` (`web/src/components/AppShell.tsx`) -Renders the top header with brand name and primary nav links, then an `` for the -active page. Wraps the entire subtree in `TimelineRefreshProvider` so any descendant can -access the refresh context. Mounts `SecurityStatusBar` between the header and the main -content area. +Renders a fixed-width **left sidebar** (`aside.fd-sidebar`) with brand and vertical primary +nav (Langfuse-style rail), then a **`fd-shell__content`** column with `SecurityStatusBar` and +`
` wrapping an `` for the active page. On narrow viewports the sidebar stacks +above the content with a horizontal nav row. Wraps the subtree in `TimelineRefreshProvider` +so any descendant can access the refresh context. + +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 @@ -105,7 +110,8 @@ Build-time configuration helpers read from `import.meta.env`: ## `SecurityStatusBar` (`web/src/components/SecurityStatusBar.tsx`) -Mounted by `AppShell` between the header and the main content area. Fetches `GET /health` +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` (`"bearer"` or `"loopback"`), then renders an info or warning strip: @@ -141,9 +147,10 @@ Read-only dashboard. Renders a **Ledger metrics** card from `fetchMetrics()` plu Long IDs are abbreviated with `shortId(id, keepStart, keepEnd)` and shown in full on hover via the HTML `title` attribute. -**Refresh:** a manual **Refresh** button in the page header calls `loadTimeline()` directly. -The `generation` counter from `TimelineRefreshContext` also triggers automatic refreshes -after mutations from `ActionsPage`. +**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`. --- @@ -357,8 +364,9 @@ All tokens are CSS custom properties on `:root`: | Token | Purpose | |-------|---------| -| `--fd-bg` | Page background | -| `--fd-surface` | Card / header background | +| `--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 | @@ -378,9 +386,10 @@ All tokens are CSS custom properties on `:root`: | Class | Description | |-------|-------------| -| `fd-shell` | Full-height flex container for header + main | -| `fd-header` | Sticky top bar | -| `fd-nav__link` | Navigation link; `--active` modifier for current route | +| `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 | @@ -393,7 +402,7 @@ All tokens are CSS custom properties on `:root`: | `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` | Full-width strip below the header; wraps `SecurityStatusBar` output | +| `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 | diff --git a/examples/README.md b/examples/README.md index e67aa26..5635299 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,6 +12,8 @@ This folder holds **copy-pasteable** references for wiring FlightDeck into a rea 6. **Run the server** in a container or compose stack — see [deploy/](deploy/README.md). The bundled UI calls **`GET /v1/workspace`** to choose direct promote vs request/confirm. 7. **Triage runs** with **`flightdeck runs list`** / **`runs export`** or **`GET /v1/runs`**, and **observe** aggregate ledger size with **`GET /v1/metrics`** (JSON counters; read-only, same access tier as other `GET /v1/*` routes). With **`flightdeck serve`**, **`/#/runs`** adds optional **Group by trace_id** (collapsible sections) on top of the same API slice. +**UI polish / operator flow:** See [docs/web-ui.md](../docs/web-ui.md) for routing and surfaces. In the bundled app, prefer **Diff** for policy and pricing conclusions, **Runs** for trace-scoped triage, and **Actions** for promote and rollback so operators rarely need raw JSON first. + ## Readiness checklist (quick pass) Use this as a **discoverability** pass for the **[ROADMAP.md](../ROADMAP.md)** success and readiness signals (not a product guarantee): diff --git a/pyproject.toml b/pyproject.toml index 4a9acb4..ddd9578 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ dev = [ "pytest>=7.0", "ruff==0.15.12", ] +postgres = [ + "psycopg[binary]>=3.2", +] [project.urls] Homepage = "https://github.com/flightdeckdev/flightdeck" diff --git a/src/flightdeck/cli/main.py b/src/flightdeck/cli/main.py index 9a3a3de..0382779 100644 --- a/src/flightdeck/cli/main.py +++ b/src/flightdeck/cli/main.py @@ -35,7 +35,7 @@ request_promotion, rollback_release, ) -from flightdeck.storage import Storage +from flightdeck.storage import storage_from_config def read_release_artifact(path: Path) -> ReleaseArtifact: @@ -94,9 +94,12 @@ def init(path_: str) -> None: def doctor_cmd(backup_path: Path | None) -> None: """Run read-only health checks on the local ledger (migrations, promoted pointers).""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) if backup_path is not None: - storage.backup_to(backup_path) + try: + storage.backup_to(backup_path) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc click.echo(f"Backed up database to {backup_path}") checks = run_doctor(storage) failed = False @@ -149,7 +152,7 @@ def release() -> None: def release_register(path: Path, environment: str | None) -> None: """Register an immutable Release artifact (file or bundle directory).""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() env = environment or cfg.default_environment @@ -179,7 +182,7 @@ def release_register(path: Path, environment: str | None) -> None: def release_list() -> None: """List registered releases.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() for r in storage.list_releases(): click.echo(f"{r.release_id}\t{r.agent_id}\t{r.version}\t{r.environment}\t{r.created_at.isoformat()}") @@ -190,7 +193,7 @@ def release_list() -> None: def release_show(release_id: str) -> None: """Show a registered release record as JSON.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() record = storage.get_release(release_id) @@ -212,7 +215,7 @@ def release_show(release_id: str) -> None: def release_verify(release_id: str, artifact_path: Path) -> None: """Verify on-disk artifact bytes match the checksum stored at registration (exit 2 on mismatch).""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() record = storage.get_release(release_id) @@ -252,7 +255,7 @@ def pricing() -> None: def pricing_import(path: Path, replace: bool, reason: str | None) -> None: """Import a pricing table YAML.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() data = yaml.safe_load(path.read_text(encoding="utf-8")) @@ -272,7 +275,7 @@ def pricing_import(path: Path, replace: bool, reason: str | None) -> None: def pricing_show(provider: str, pricing_version: str) -> None: """Show a pricing table by provider/version.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() table = storage.get_pricing_table(provider, pricing_version) @@ -291,7 +294,7 @@ def policy() -> None: def policy_set(path: Path) -> None: """Set the active promotion policy from YAML.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} @@ -304,7 +307,7 @@ def policy_set(path: Path) -> None: def policy_show() -> None: """Show the active promotion policy.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() p = storage.get_active_policy() or default_policy() @@ -349,7 +352,7 @@ def runs_list( ) -> None: """List ingested run events for a release (newest first; truncated to --limit).""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() try: payload = query_run_events_page( @@ -413,7 +416,7 @@ def runs_export( ) -> None: """Export run events as JSONL (newest first), same filters as ``runs list`` (truncated to --limit, max 500).""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() try: payload = query_run_events_page( @@ -453,7 +456,7 @@ def runs_export( def runs_ingest(path: Path) -> None: """Ingest RunEvent JSONL (or JSON array) into local storage.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() events = parse_events_file(path) @@ -494,7 +497,7 @@ def release_diff( ) -> None: """Compare two releases over a time window and print a confidence-labeled safety diff.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() try: result = compute_diff( @@ -598,7 +601,7 @@ def release_diff( def release_promote(release_id: str, environment: str, window: str, reason: str) -> None: """Promote a release after evaluating active policy against the current baseline.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() try: outcome = promote_release( @@ -633,7 +636,7 @@ def release_promote(release_id: str, environment: str, window: str, reason: str) def release_promote_request(release_id: str, environment: str, window: str, reason: str) -> None: """Create a pending promotion request (requires promotion_requires_approval in flightdeck.yaml).""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() try: record = request_promotion( @@ -657,7 +660,7 @@ def release_promote_request(release_id: str, environment: str, window: str, reas def release_promote_confirm(request_id: str, approval_reason: str) -> None: """Confirm a pending promotion request and perform the promotion.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() try: outcome = confirm_promotion_request( @@ -691,7 +694,7 @@ def release_promote_confirm(request_id: str, approval_reason: str) -> None: def release_rollback(release_id: str, environment: str, window: str, reason: str) -> None: """Roll back to a prior release (audit record + promoted pointer update).""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() try: outcome = rollback_release( @@ -724,7 +727,7 @@ def release_rollback(release_id: str, environment: str, window: str, reason: str def release_history(agent_id: str | None, environment: str | None) -> None: """Show release promotion decision history.""" cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() records = storage.list_release_actions(agent_id=agent_id, environment=environment) diff --git a/src/flightdeck/models.py b/src/flightdeck/models.py index ef27555..39f4643 100644 --- a/src/flightdeck/models.py +++ b/src/flightdeck/models.py @@ -17,6 +17,9 @@ class WorkspaceConfig(BaseModel): kind: Literal["WorkspaceConfig"] = "WorkspaceConfig" db_path: str = ".flightdeck/flightdeck.db" + # When set (``postgresql://`` or ``postgres://``), the ledger uses PostgreSQL instead of + # the SQLite file at ``db_path``. Requires optional dependency ``psycopg`` (``--extra postgres``). + database_url: str | None = None default_environment: str = "local" diff: DiffConfig = Field(default_factory=DiffConfig) diff --git a/src/flightdeck/server/app.py b/src/flightdeck/server/app.py index e4e63f3..63d16c8 100644 --- a/src/flightdeck/server/app.py +++ b/src/flightdeck/server/app.py @@ -10,14 +10,14 @@ from flightdeck.config import load_config from flightdeck.server.routes import include_routes -from flightdeck.storage import Storage +from flightdeck.storage import storage_from_config def create_app() -> FastAPI: @asynccontextmanager async def lifespan(app: FastAPI): cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() app.state.cfg = cfg app.state.storage = storage diff --git a/src/flightdeck/server/routes/common.py b/src/flightdeck/server/routes/common.py index a12ccc1..cef1a80 100644 --- a/src/flightdeck/server/routes/common.py +++ b/src/flightdeck/server/routes/common.py @@ -5,7 +5,7 @@ from flightdeck.config import load_config from flightdeck.models import WorkspaceConfig -from flightdeck.storage import Storage +from flightdeck.storage import Storage, storage_from_config def ensure_app_state(request: Request) -> tuple[WorkspaceConfig, Storage]: @@ -15,7 +15,7 @@ def ensure_app_state(request: Request) -> tuple[WorkspaceConfig, Storage]: return cfg, storage cfg = load_config() - storage = Storage(cfg.db_path) + storage = storage_from_config(cfg) storage.migrate() request.app.state.cfg = cfg request.app.state.storage = storage diff --git a/src/flightdeck/storage.py b/src/flightdeck/storage.py index 9d10dca..28751a0 100644 --- a/src/flightdeck/storage.py +++ b/src/flightdeck/storage.py @@ -10,6 +10,17 @@ from typing import Any, Iterable from uuid import uuid4 +from flightdeck.db_connect import ( + DbConn, + configure_sqlite, + detect_backend, + is_unique_violation, + json_request_field_predicate, + open_postgres, + open_postgres_transaction, + open_sqlite, + require_psycopg, +) from flightdeck.models import ( Policy, PolicyResult, @@ -18,6 +29,7 @@ PromotionRequestRecord, ReleaseRecord, RunEvent, + WorkspaceConfig, utc_now, ) @@ -26,41 +38,70 @@ def ensure_parent_dir(db_path: str) -> None: Path(db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True) +def storage_from_config(cfg: WorkspaceConfig) -> Storage: + """Open storage from workspace YAML (SQLite ``db_path`` or optional ``database_url``).""" + url = (cfg.database_url or "").strip() + if url: + if not url.startswith(("postgresql://", "postgres://")): + msg = ( + "database_url must start with postgresql:// or postgres:// when set; " + "otherwise omit database_url and use db_path for SQLite." + ) + raise ValueError(msg) + require_psycopg() + return Storage(dsn=url) + return Storage(dsn=cfg.db_path) + + # Highest migration version applied by `Storage.migrate()` — keep in sync with migration blocks below. LATEST_SCHEMA_MIGRATION_VERSION = 4 @dataclass(frozen=True) class Storage: - db_path: str + """Ledger access. ``dsn`` is a SQLite file path or a ``postgresql://`` URL.""" - @staticmethod - def _configure_connection(conn: sqlite3.Connection) -> None: - # Improve concurrency + reduce "database is locked" surprises on Windows. - conn.execute("PRAGMA foreign_keys=ON;") - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA synchronous=NORMAL;") - conn.execute("PRAGMA busy_timeout=5000;") - - def connect(self) -> sqlite3.Connection: - ensure_parent_dir(self.db_path) - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - self._configure_connection(conn) - return conn + dsn: str + + @property + def db_path(self) -> str: + """Backward-compatible: SQLite path, or the PostgreSQL DSN when using ``database_url``.""" + return self.dsn + + @property + def dialect(self) -> str: + return detect_backend(self.dsn) + + @contextmanager + def connect(self) -> Any: + if self.dialect == "sqlite": + ensure_parent_dir(self.dsn) + with open_sqlite(self.dsn) as conn: + yield conn + else: + with open_postgres(self.dsn) as conn: + yield conn @contextmanager def transaction(self) -> Any: - conn = self.connect() - try: - conn.execute("BEGIN IMMEDIATE;") - yield conn - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() + if self.dialect == "sqlite": + ensure_parent_dir(self.dsn) + conn = sqlite3.connect(self.dsn) + conn.row_factory = sqlite3.Row + configure_sqlite(conn) + try: + conn.execute("BEGIN IMMEDIATE") + try: + yield DbConn("sqlite", conn, None) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + else: + with open_postgres_transaction(self.dsn) as conn: + yield conn def migrate(self) -> None: with self.connect() as conn: @@ -187,7 +228,7 @@ def apply(version: int, statements: list[str]) -> None: # v3: monotonic audit_seq on release_actions (append-only promotion/rollback ledger). if 3 not in applied: - cols = {r["name"] for r in conn.execute("PRAGMA table_info(release_actions)").fetchall()} + cols = conn.table_columns("release_actions") if "audit_seq" not in cols: conn.execute("ALTER TABLE release_actions ADD COLUMN audit_seq INTEGER") pending = conn.execute( @@ -198,8 +239,10 @@ def apply(version: int, statements: list[str]) -> None: """ ).fetchall() if pending: - base_row = conn.execute("SELECT COALESCE(MAX(audit_seq), 0) FROM release_actions").fetchone() - n = int(base_row[0]) if base_row is not None else 0 + base_row = conn.execute( + "SELECT COALESCE(MAX(audit_seq), 0) AS n FROM release_actions" + ).fetchone() + n = int(base_row["n"]) if base_row is not None else 0 for pr in pending: n += 1 conn.execute( @@ -242,6 +285,9 @@ def backup_to(self, dest: Path) -> None: Creates parent directories. Overwrites ``dest`` if it already exists. ``dest`` must not be the same path as :attr:`db_path`. """ + if self.dialect != "sqlite": + msg = "backup_to is only supported for SQLite workspaces (db_path); use pg_dump for PostgreSQL" + raise ValueError(msg) dest_path = dest.expanduser().resolve() dest_path.parent.mkdir(parents=True, exist_ok=True) source_path = Path(self.db_path).expanduser().resolve() @@ -250,7 +296,11 @@ def backup_to(self, dest: Path) -> None: raise ValueError(msg) ensure_parent_dir(self.db_path) self.migrate() - with self.connect() as src: + with self.connect() as src_wrap: + src = src_wrap.raw_sqlite + if src is None: + msg = "internal error: SQLite backup requires raw_sqlite connection" + raise RuntimeError(msg) dst = sqlite3.connect(str(dest_path)) try: src.backup(dst) @@ -279,7 +329,7 @@ def check_release_actions_audit_seq(self) -> tuple[bool, str]: """ self.migrate() with self.connect() as conn: - cols = {r["name"] for r in conn.execute("PRAGMA table_info(release_actions)").fetchall()} + cols = conn.table_columns("release_actions") if "audit_seq" not in cols: return False, "release_actions has no audit_seq column (migrations incomplete?)" rows = conn.execute("SELECT audit_seq FROM release_actions ORDER BY audit_seq").fetchall() @@ -640,7 +690,7 @@ def get_promoted_release_id(self, agent_id: str, environment: str) -> str | None return str(row["release_id"]) @staticmethod - def _set_promoted_release_conn(conn: sqlite3.Connection, agent_id: str, environment: str, release_id: str) -> None: + def _set_promoted_release_conn(conn: DbConn, agent_id: str, environment: str, release_id: str) -> None: conn.execute( """ INSERT INTO promoted_releases (agent_id, environment, release_id, promoted_at) @@ -788,9 +838,12 @@ def insert_run_events(self, events: Iterable[RunEvent]) -> int: row, ) inserted += 1 - except sqlite3.IntegrityError: - # idempotent ingestion - pass + except Exception as exc: + if is_unique_violation(self.dialect, exc): + # idempotent ingestion + pass + else: + raise return inserted def query_runs( @@ -818,13 +871,13 @@ def query_runs( clauses.append("environment = ?") params.append(environment) if trace_id: - clauses.append("json_extract(event_json, '$.request.trace_id') = ?") + clauses.append(json_request_field_predicate("event_json", "trace_id", self.dialect)) params.append(trace_id) if session_id: - clauses.append("json_extract(event_json, '$.request.session_id') = ?") + clauses.append(json_request_field_predicate("event_json", "session_id", self.dialect)) params.append(session_id) if span_id: - clauses.append("json_extract(event_json, '$.request.span_id') = ?") + clauses.append(json_request_field_predicate("event_json", "span_id", self.dialect)) params.append(span_id) where = " AND ".join(clauses) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index d97baba..a703756 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -134,7 +134,8 @@ def test_insert_promotion_record_uses_immediate_transaction(tmp_path: Path) -> N created_at=datetime.now(tz=timezone.utc), ) - competing_conn = storage.connect() + competing_conn = sqlite3.connect(str(tmp_path / "flightdeck.db")) + competing_conn.execute("PRAGMA busy_timeout=5000") try: competing_conn.execute("BEGIN IMMEDIATE;") try: diff --git a/web/README.md b/web/README.md index cab551a..b684d4e 100644 --- a/web/README.md +++ b/web/README.md @@ -50,7 +50,7 @@ npm run test:e2e **`playwright.config.ts`** starts **`scripts/e2e-server.mjs`**: a fresh workspace under **`.tmp/playwright-fd-workspace/`**, then **`flightdeck serve`** on **`http://127.0.0.1:9876`**. On GitHub Actions the server uses **`uv run flightdeck …`**; locally it uses **`python -m flightdeck.cli.main`** or **`py -3`**. -The default **`npm run test:e2e`** suite expects **`promotion_requires_approval: false`** in that workspace. A stray shell **`FD_E2E_FORCE_APPROVAL=1`** alone does **not** flip the server: **`e2e-server.mjs`** only patches YAML when **`PW_FORCE_APPROVAL_WORKSPACE=1`**, which Playwright sets when the CLI targets **`e2e/actions-approval.spec.ts`** or when **`PW_WEBSERVER_APPROVAL=1`** (CI’s second Playwright step sets both). Run approval tests with **`FD_E2E_FORCE_APPROVAL=1 npx playwright test e2e/actions-approval.spec.ts`** (or the same env vars as CI); do not combine **`FD_E2E_FORCE_APPROVAL=1`** with the full default suite unless you intend the approval-only assertions to run against a non-approval workspace (they will fail or skip). +The default **`npm run test:e2e`** suite expects **`promotion_requires_approval: false`** in that workspace. A stray shell **`FD_E2E_FORCE_APPROVAL=1`** does **not** flip the server by itself: **`e2e-server.mjs`** only patches YAML when **`PW_FORCE_APPROVAL_WORKSPACE=1`**, which **`playwright.config.ts`** sets when the Playwright CLI lists **exactly one** `e2e/*.spec.ts` argument and it is **`e2e/actions-approval.spec.ts`**. Run approval tests with **`FD_E2E_FORCE_APPROVAL=1 npx playwright test e2e/actions-approval.spec.ts`** (that single-file form both enables the approval workspace and un-skips the describe block). Do not pass **`e2e/actions-approval.spec.ts`** together with other spec paths unless you intend a split server mode (the server is one workspace for the whole run). Run **`npm`** commands from this **`web/`** directory (repo root is one level up: **`cd web`**). diff --git a/web/e2e/actions-approval.spec.ts b/web/e2e/actions-approval.spec.ts index 15a63a3..0e75cb7 100644 --- a/web/e2e/actions-approval.spec.ts +++ b/web/e2e/actions-approval.spec.ts @@ -3,6 +3,16 @@ import { expect, test } from "@playwright/test"; test.describe("approval-required workspace (FD_E2E_FORCE_APPROVAL=1)", () => { test.skip(!process.env.FD_E2E_FORCE_APPROVAL, "set FD_E2E_FORCE_APPROVAL=1 (see CI workflow)"); + test.beforeAll(async ({ request }) => { + const res = await request.get("/v1/workspace"); + expect(res.ok()).toBeTruthy(); + const j = await res.json(); + test.skip( + j.promotion_requires_approval !== true, + "server is not the approval workspace (run this file alone: npx playwright test e2e/actions-approval.spec.ts)", + ); + }); + test("actions page shows request/confirm flow", async ({ page }) => { await page.goto("/#/actions"); await expect(page.getByText("human approval required")).toBeVisible(); diff --git a/web/playwright.config.ts b/web/playwright.config.ts index d373891..d7873ac 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -8,13 +8,18 @@ const e2eServer = path.join(__dirname, "scripts", "e2e-server.mjs"); const port = process.env.FD_E2E_PORT || "9876"; const baseURL = `http://127.0.0.1:${port}`; -/** Patch workspace YAML for approval only when that spec (or CI) requests it — not for `FD_*` alone. */ +/** Patch workspace YAML for approval only when the CLI targets that file alone — not env leaks. */ function e2eServerShouldForceApproval(): boolean { - if (process.env.PW_WEBSERVER_APPROVAL === "1") return true; - return process.argv.some((arg) => { + // Full `playwright test` can pass multiple `e2e/*.spec.ts` paths in argv; only enable approval + // workspace when **exactly one** e2e spec file is targeted and it is **`actions-approval.spec.ts`**. + // (Do not key off `PW_WEBSERVER_APPROVAL`: a stale `=1` in the shell breaks the default suite.) + const specs = process.argv.filter((arg) => { const n = arg.replace(/\\/g, "/"); - return /(^|\/)e2e\/actions-approval\.spec\.ts$/.test(n); + return /(^|\/)e2e\/[^/]+\.spec\.ts$/.test(n); }); + if (specs.length !== 1) return false; + const n = specs[0].replace(/\\/g, "/"); + return /(^|\/)e2e\/actions-approval\.spec\.ts$/.test(n); } const webServerEnv = { ...process.env, PW_FORCE_APPROVAL_WORKSPACE: e2eServerShouldForceApproval() ? "1" : "0" }; diff --git a/web/scripts/e2e-server.mjs b/web/scripts/e2e-server.mjs index cfeef2c..79e2657 100644 --- a/web/scripts/e2e-server.mjs +++ b/web/scripts/e2e-server.mjs @@ -38,8 +38,8 @@ if (inCi) { await run("python", ["-m", "flightdeck.cli.main", "init"], { cwd: ws }); } -// Set by `playwright.config.ts` only when the approval spec (or PW_WEBSERVER_APPROVAL) runs — -// not when a shell leaks `FD_E2E_FORCE_APPROVAL=1` during the default full suite. +// Set by `playwright.config.ts` only when the Playwright CLI targets `e2e/actions-approval.spec.ts` +// alone — not when a shell leaks `FD_E2E_FORCE_APPROVAL=1` during the default full suite. if (process.env.PW_FORCE_APPROVAL_WORKSPACE === "1") { const cfgPath = path.join(ws, "flightdeck.yaml"); let text = fs.readFileSync(cfgPath, "utf8"); diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 9e7666e..a136a5a 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -1,3 +1,4 @@ +import type { MouseEvent } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { TimelineRefreshProvider } from "../context/TimelineRefreshContext"; import { SecurityStatusBar } from "./SecurityStatusBar"; @@ -6,16 +7,24 @@ import { UI_READ_ONLY } from "../uiConfig"; const navCls = ({ isActive }: { isActive: boolean }) => `fd-nav__link${isActive ? " fd-nav__link--active" : ""}`; +function skipToMain(e: MouseEvent) { + e.preventDefault(); + document.getElementById("main-content")?.focus({ preventScroll: false }); +} + export function AppShell() { return (
-
-
-

FlightDeck

-

Diffs, evidence, policy gates

+ + Skip to main content + +
- -
- -
+ +
+ +
+ +
+
); -} +} \ No newline at end of file diff --git a/web/src/components/SecurityStatusBar.tsx b/web/src/components/SecurityStatusBar.tsx index f565ba8..800d81c 100644 --- a/web/src/components/SecurityStatusBar.tsx +++ b/web/src/components/SecurityStatusBar.tsx @@ -87,6 +87,18 @@ export function SecurityStatusBar() { ? "This UI build sends a client token (VITE_FLIGHTDECK_LOCAL_API_TOKEN is set)." : "This UI build does not send a client token (VITE_FLIGHTDECK_LOCAL_API_TOKEN unset)."; + if (auth === "bearer" && hasClient) { + return ( +
+

+ Bearer mutations: VITE_FLIGHTDECK_LOCAL_API_TOKEN is set — + confirm it matches the server's{" "} + FLIGHTDECK_LOCAL_API_TOKEN. +

+
+ ); + } + return (
{mismatch ? ( diff --git a/web/src/index.css b/web/src/index.css index 59204dd..a7a4901 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,12 +1,12 @@ :root { color-scheme: light; - --fd-bg: #f4f5f7; + --fd-bg: #f3f4f6; --fd-surface: #ffffff; - --fd-surface-2: #fafbfc; - --fd-border: #e1e4e8; - --fd-border-strong: #c9ccd1; - --fd-text: #1a1d21; - --fd-muted: #5c6570; + --fd-surface-2: #f8f9fb; + --fd-border: #dfe3e8; + --fd-border-strong: #c4c9d1; + --fd-text: #171a1f; + --fd-muted: #525a63; --fd-accent: #0d6efd; --fd-accent-hover: #0b5ed7; --fd-pass-bg: #e8f5e9; @@ -18,6 +18,18 @@ --fd-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); --fd-font: "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, sans-serif; --fd-mono: ui-monospace, "Cascadia Code", "Consolas", monospace; + /* Focus ring: visible on keyboard, sober contrast */ + --fd-focus-ring: 0 0 0 2px var(--fd-surface), 0 0 0 4px rgba(13, 110, 253, 0.45); + --fd-type-page-title: 1.375rem; + --fd-type-page-title--lh: 1.25; + --fd-type-page-sub: 0.9375rem; + --fd-type-page-sub--lh: 1.45; + --fd-type-card-title: 1.0625rem; + --fd-type-card-title--lh: 1.3; + --fd-type-table: 0.875rem; + --fd-type-table--lh: 1.45; + --fd-type-table-head: 0.75rem; + --fd-sidebar-width: 15.5rem; font-family: var(--fd-font); line-height: 1.5; color: var(--fd-text); @@ -36,58 +48,90 @@ body { } .fd-shell { + position: relative; min-height: 100vh; display: flex; - flex-direction: column; + flex-direction: row; + align-items: stretch; } -.fd-header { +/* Langfuse-style: fixed left rail + scrollable main column */ +.fd-sidebar { + position: sticky; + top: 0; + align-self: flex-start; + flex: 0 0 var(--fd-sidebar-width); + width: var(--fd-sidebar-width); + max-width: 100%; + height: 100vh; + max-height: 100vh; + overflow-x: hidden; + overflow-y: auto; display: flex; - flex-wrap: wrap; - align-items: flex-end; - justify-content: space-between; - gap: 1rem 2rem; - padding: 1rem 1.5rem; + flex-direction: column; + gap: 1.15rem; + padding: 1.1rem 0.75rem 1.5rem; background: var(--fd-surface); - border-bottom: 1px solid var(--fd-border); - box-shadow: var(--fd-shadow); + border-right: 1px solid var(--fd-border); + box-shadow: 1px 0 0 rgba(15, 23, 42, 0.04); } -.fd-header__brand { +.fd-sidebar__brand { min-width: 0; + padding: 0 0.35rem; } -.fd-header__title { +.fd-sidebar__title { margin: 0; - font-size: 1.35rem; - font-weight: 650; + font-size: 1.125rem; + font-weight: 700; letter-spacing: -0.02em; + line-height: 1.2; } -.fd-header__tagline { - margin: 0.15rem 0 0; - font-size: 0.875rem; +.fd-sidebar__tagline { + margin: 0.35rem 0 0; + font-size: 0.75rem; + line-height: 1.35; color: var(--fd-muted); } -.fd-nav { +.fd-sidebar__nav { display: flex; - gap: 0.25rem; - flex-wrap: wrap; + flex-direction: column; + gap: 0.12rem; + flex: 1; + min-height: 0; +} + +.fd-shell__content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--fd-bg); } .fd-nav__link { - padding: 0.45rem 0.85rem; + display: block; + width: 100%; + padding: 0.5rem 0.75rem; border-radius: var(--fd-radius-sm); font-size: 0.9rem; font-weight: 500; color: var(--fd-muted); text-decoration: none; border: 1px solid transparent; - transition: - background 0.12s, - color 0.12s, - border-color 0.12s; + border-left: 3px solid transparent; +} + +@media (prefers-reduced-motion: no-preference) { + .fd-nav__link { + transition: + background 0.14s ease, + color 0.14s ease, + border-color 0.14s ease; + } } .fd-nav__link:hover { @@ -95,18 +139,28 @@ body { background: var(--fd-surface-2); } +.fd-nav__link:focus { + outline: none; +} + +.fd-nav__link:focus-visible { + box-shadow: var(--fd-focus-ring); +} + .fd-nav__link--active { color: var(--fd-text); background: var(--fd-surface-2); border-color: var(--fd-border); + border-left-color: var(--fd-accent); } .fd-main { flex: 1; width: 100%; - max-width: 1120px; + max-width: min(72rem, calc(100vw - 1.5rem)); margin: 0 auto; - padding: 1.25rem 1.5rem 2.5rem; + padding: 1.35rem 1.25rem 2.75rem; + outline: none; } .fd-page-head { @@ -114,20 +168,23 @@ body { flex-wrap: wrap; align-items: flex-start; justify-content: space-between; - gap: 1rem; - margin-bottom: 1.25rem; + gap: 0.85rem 1rem; + margin-bottom: 1.5rem; } .fd-page-title { margin: 0; - font-size: 1.25rem; + font-size: var(--fd-type-page-title); + line-height: var(--fd-type-page-title--lh); font-weight: 650; + letter-spacing: -0.015em; } .fd-page-sub { - margin: 0.35rem 0 0; + margin: 0.4rem 0 0; max-width: 52ch; - font-size: 0.9rem; + font-size: var(--fd-type-page-sub); + line-height: var(--fd-type-page-sub--lh); color: var(--fd-muted); } @@ -135,13 +192,13 @@ body { background: var(--fd-surface); border: 1px solid var(--fd-border); border-radius: var(--fd-radius); - padding: 1rem 1.15rem; - margin-bottom: 1rem; + padding: 1.05rem 1.2rem; + margin-bottom: 1.125rem; box-shadow: var(--fd-shadow); } .fd-card__head { - margin-bottom: 0.75rem; + margin-bottom: 0.8rem; } .fd-card__head--row { @@ -159,8 +216,10 @@ body { .fd-card__title { margin: 0; - font-size: 1.05rem; + font-size: var(--fd-type-card-title); + line-height: var(--fd-type-card-title--lh); font-weight: 600; + letter-spacing: -0.01em; } .fd-card__subtitle { @@ -178,12 +237,16 @@ body { .fd-table-wrap { overflow-x: auto; margin: 0 -0.15rem; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + scrollbar-gutter: stable; } .fd-table { width: 100%; border-collapse: collapse; - font-size: 0.875rem; + font-size: var(--fd-type-table); + line-height: var(--fd-type-table--lh); } .fd-table th, @@ -197,9 +260,9 @@ body { .fd-table th { font-weight: 600; color: var(--fd-muted); - font-size: 0.78rem; + font-size: var(--fd-type-table-head); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.045em; } .fd-table tbody tr:last-child td { @@ -210,6 +273,12 @@ body { background: var(--fd-surface-2); } +@media (prefers-reduced-motion: no-preference) { + .fd-table tbody tr:hover td { + transition: background 0.12s ease; + } +} + .fd-mono { font-family: var(--fd-mono); font-size: 0.82rem; @@ -292,6 +361,23 @@ body { font-weight: 500; } +@media (prefers-reduced-motion: no-preference) { + .fd-btn { + transition: + background 0.12s ease, + border-color 0.12s ease, + color 0.12s ease; + } +} + +.fd-btn:focus { + outline: none; +} + +.fd-btn:focus-visible { + box-shadow: var(--fd-focus-ring); +} + .fd-btn:hover:not(:disabled) { background: var(--fd-surface-2); } @@ -312,6 +398,10 @@ body { border-color: var(--fd-accent-hover); } +.fd-btn--primary:focus-visible { + box-shadow: var(--fd-focus-ring); +} + .fd-btn--ghost { background: transparent; } @@ -358,9 +448,21 @@ body { color: var(--fd-text); } +@media (prefers-reduced-motion: no-preference) { + .fd-input { + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; + } +} + .fd-input:focus { - outline: 2px solid rgba(13, 110, 253, 0.35); - outline-offset: 1px; + outline: none; +} + +.fd-input:focus-visible { + border-color: var(--fd-accent); + box-shadow: 0 0 0 1px var(--fd-surface), 0 0 0 3px rgba(13, 110, 253, 0.28); } .fd-actions { @@ -395,8 +497,9 @@ body { } .fd-security-strip { - padding: 0 1.5rem; - margin-top: 0.5rem; + flex-shrink: 0; + padding: 0.5rem 1.25rem 0; + margin-top: 0; } .fd-security-strip__msg { @@ -524,6 +627,15 @@ body { accent-color: var(--fd-accent); } +.fd-checkbox-label input:focus { + outline: none; +} + +.fd-checkbox-label input:focus-visible { + outline: 2px solid rgba(13, 110, 253, 0.5); + outline-offset: 3px; +} + .fd-trace-groups { display: flex; flex-direction: column; @@ -542,6 +654,15 @@ body { font-size: 0.9rem; padding: 0.45rem 0.35rem; list-style-position: outside; + border-radius: var(--fd-radius-sm); +} + +.fd-trace-group__summary:focus { + outline: none; +} + +.fd-trace-group__summary:focus-visible { + box-shadow: var(--fd-focus-ring); } .fd-trace-group__summary::-webkit-details-marker { @@ -579,6 +700,53 @@ body { border: 0; } +/* First focusable in shell: keyboard skip to #main-content */ +.fd-skip-link { + position: absolute; + left: 0.75rem; + top: 0.55rem; + z-index: 200; + padding: 0.45rem 0.75rem; + font-size: 0.875rem; + font-weight: 600; + font-family: inherit; + line-height: 1.3; + color: var(--fd-text); + background: var(--fd-surface); + border: 1px solid var(--fd-border-strong); + border-radius: var(--fd-radius-sm); + box-shadow: var(--fd-shadow); + text-decoration: none; + transform: translateY(-160%); + opacity: 0; + pointer-events: none; +} + +.fd-skip-link:focus { + outline: none; +} + +.fd-skip-link:focus-visible { + transform: translateY(0); + opacity: 1; + pointer-events: auto; + box-shadow: var(--fd-focus-ring); +} + +@media (prefers-reduced-motion: reduce) { + .fd-skip-link { + transition: none; + } +} + +@media (prefers-reduced-motion: no-preference) { + .fd-skip-link { + transition: + transform 0.16s ease, + opacity 0.16s ease; + } +} + .fd-btn--sm { padding: 0.28rem 0.55rem; font-size: 0.82rem; @@ -788,6 +956,13 @@ body { } } +@media (prefers-reduced-motion: reduce) { + .fd-skeleton { + animation: none; + background: var(--fd-surface-2); + } +} + .fd-inline-nav { margin: 0.65rem 0 0; font-size: 0.85rem; @@ -803,3 +978,61 @@ body { .fd-inline-nav a:hover { text-decoration: underline; } + +/* Narrow viewports: stack sidebar above content; tables may wrap long tokens */ +@media (max-width: 42rem) { + .fd-shell { + flex-direction: column; + } + + .fd-sidebar { + position: relative; + top: auto; + align-self: stretch; + flex: none; + width: 100%; + height: auto; + max-height: none; + overflow: visible; + border-right: none; + border-bottom: 1px solid var(--fd-border); + box-shadow: none; + padding: 0.75rem 1rem 0.85rem; + gap: 0.65rem; + } + + .fd-sidebar__nav { + flex-direction: row; + flex-wrap: wrap; + gap: 0.25rem; + } + + .fd-sidebar__nav .fd-nav__link { + width: auto; + flex: 0 1 auto; + } + + .fd-sidebar__nav .fd-nav__link--active { + border-color: transparent; + border-left-color: var(--fd-accent); + } + + .fd-security-strip { + padding-inline: 1rem; + } + + .fd-main { + padding-inline: 1rem; + max-width: 100%; + } + + .fd-table th, + .fd-table td { + overflow-wrap: anywhere; + word-break: break-word; + } + + .fd-checkbox-label { + white-space: normal; + } +} diff --git a/web/src/pages/ActionsPage.tsx b/web/src/pages/ActionsPage.tsx index b65730b..018434e 100644 --- a/web/src/pages/ActionsPage.tsx +++ b/web/src/pages/ActionsPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import type { ActionOutcomePayload, PromotionRequestListItem, WorkspacePublicPayload } from "../api"; -import { fetchJson, fetchPromotionRequests, fetchWorkspace } from "../api"; +import { fetchHealth, fetchJson, fetchPromotionRequests, fetchWorkspace } from "../api"; +import { clientMutationTokenConfigured } from "../uiConfig"; import { Badge } from "../components/Badge"; import { JsonPanel } from "../components/JsonPanel"; import { useTimelineRefresh } from "../context/TimelineRefreshContext"; @@ -78,6 +79,9 @@ export function ActionsPage() { const [confirmReason, setConfirmReason] = useState(""); const [requestRaw, setRequestRaw] = useState(null); + const [mutationAuth, setMutationAuth] = useState<"bearer" | "loopback" | null>(null); + const [healthChecked, setHealthChecked] = useState(false); + const refreshPending = useCallback(async () => { if (!workspace?.promotion_requires_approval) return; setPendingErr(null); @@ -114,6 +118,26 @@ export function ActionsPage() { }; }, []); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const h = await fetchHealth(); + if (!cancelled) { + const m = h.mutation_auth; + setMutationAuth(m === "bearer" || m === "loopback" ? m : null); + } + } catch { + if (!cancelled) setMutationAuth(null); + } finally { + if (!cancelled) setHealthChecked(true); + } + })(); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { void refreshPending(); }, [refreshPending, listNonce]); @@ -245,6 +269,9 @@ export function ActionsPage() { const approvalOn = workspace?.promotion_requires_approval === true; const canMutate = !workspaceLoading && workspace !== null; + const clientTokenOn = clientMutationTokenConfigured(); + const showBearerTokenHint = + canMutate && healthChecked && mutationAuth === "bearer" && !clientTokenOn; return ( <> @@ -362,8 +389,13 @@ export function ActionsPage() {

Loading workspace mode…

- ) : null} -
+ ) : ( +

+ VITE_FLIGHTDECK_LOCAL_API_TOKEN{" "} + {clientTokenOn ? "is configured for this UI build." : "is not set in this UI build."} +

+ )} +
{approvalOn ? ( + {showBearerTokenHint ? ( + + Server uses Bearer for mutations — set{" "} + VITE_FLIGHTDECK_LOCAL_API_TOKEN to match the server. + + ) : null}
diff --git a/web/src/pages/OverviewPage.tsx b/web/src/pages/OverviewPage.tsx index 196b5ca..7b38dc4 100644 --- a/web/src/pages/OverviewPage.tsx +++ b/web/src/pages/OverviewPage.tsx @@ -6,6 +6,8 @@ import { useTimelineRefresh } from "../context/TimelineRefreshContext"; import { Badge } from "../components/Badge"; import { JsonPanel } from "../components/JsonPanel"; +const OVERVIEW_POLL_MS = 30_000; + function shortId(id: string, keepStart = 10, keepEnd = 6) { if (id.length <= keepStart + keepEnd + 1) return id; return `${id.slice(0, keepStart)}…${id.slice(-keepEnd)}`; @@ -42,8 +44,11 @@ export function OverviewPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); - const refresh = useCallback(async () => { - setLoading(true); + const refresh = useCallback(async (opts?: { silent?: boolean }) => { + const silent = opts?.silent === true; + if (!silent) { + setLoading(true); + } setError(null); setMetricsError(null); try { @@ -61,14 +66,24 @@ export function OverviewPage() { setMetrics(null); } } finally { - setLoading(false); + if (!silent) { + setLoading(false); + } } }, []); useEffect(() => { - void refresh(); + void refresh({ silent: generation > 0 }); }, [generation, refresh]); + useEffect(() => { + const id = window.setInterval(() => { + if (document.visibilityState !== "visible") return; + void refresh({ silent: true }); + }, OVERVIEW_POLL_MS); + return () => window.clearInterval(id); + }, [refresh]); + const raw = data === null ? error ?? "" @@ -83,11 +98,12 @@ export function OverviewPage() {

Overview

-

Registered releases, promotion pointers, and recent ledger actions.

+

+ Registered releases, promotion pointers, and recent ledger actions. Refreshes automatically every{" "} + {OVERVIEW_POLL_MS / 1000}s while this tab is visible; also updates after promote or rollback from{" "} + Actions. +

-
{error && !loading ?

{error}

: null} diff --git a/web/src/pages/RunsPage.tsx b/web/src/pages/RunsPage.tsx index 56a1cbd..80cd0fd 100644 --- a/web/src/pages/RunsPage.tsx +++ b/web/src/pages/RunsPage.tsx @@ -27,6 +27,49 @@ function getTraceId(ev: Record): string { return typeof t === "string" ? t : ""; } +function getSessionId(ev: Record): string { + const req = getRequest(ev); + const v = req?.session_id; + return typeof v === "string" ? v : ""; +} + +function getSpanId(ev: Record): string { + const req = getRequest(ev); + const v = req?.span_id; + return typeof v === "string" ? v : ""; +} + +type RunsQueryErrorKind = "network" | "client" | "server" | "unknown"; + +function classifyRunsFetchError(e: unknown): { kind: RunsQueryErrorKind; title: string; detail: string } { + const detail = e instanceof Error ? e.message : String(e); + const lower = detail.toLowerCase(); + if ( + e instanceof TypeError || + lower.includes("failed to fetch") || + lower.includes("networkerror") || + lower.includes("load failed") || + lower.includes("network request failed") + ) { + return { + kind: "network", + title: "Could not reach the server", + detail: detail || "Check your connection, VPN, and that FlightDeck is running.", + }; + } + const httpMatch = detail.match(/HTTP\s+(\d{3})\b/i); + if (httpMatch) { + const code = Number.parseInt(httpMatch[1], 10); + if (code >= 500) { + return { kind: "server", title: `Server error (${code})`, detail }; + } + if (code >= 400) { + return { kind: "client", title: `Request rejected (${code})`, detail }; + } + } + return { kind: "unknown", title: "Run query failed", detail }; +} + function getLatencyMs(ev: Record): number | null { const m = getMetrics(ev); const n = m?.latency_ms; @@ -59,6 +102,8 @@ function buildTraceGroups(events: unknown[]): { key: string; rows: Record(null); + const drawerPanelRef = useRef(null); + const drawerReturnFocusRef = useRef(null); const [releases, setReleases] = useState([]); const [releaseId, setReleaseId] = useState(""); @@ -74,6 +119,11 @@ export function RunsPage() { const [result, setResult] = useState(null); const [rawErr, setRawErr] = useState(null); + const [runsQueryError, setRunsQueryError] = useState<{ + kind: RunsQueryErrorKind; + title: string; + detail: string; + } | null>(null); const [busy, setBusy] = useState(false); const [exportBusy, setExportBusy] = useState(false); const [detailEvent, setDetailEvent] = useState | null>(null); @@ -87,14 +137,25 @@ export function RunsPage() { }); }, []); + const closeDrawer = useCallback(() => { + setDetailEvent(null); + window.setTimeout(() => { + drawerReturnFocusRef.current?.focus(); + drawerReturnFocusRef.current = null; + }, 0); + }, []); + useEffect(() => { if (!detailEvent) return; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setDetailEvent(null); + if (e.key === "Escape") { + e.preventDefault(); + closeDrawer(); + } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [detailEvent]); + }, [detailEvent, closeDrawer]); useEffect(() => { if (!detailEvent) return; @@ -102,8 +163,44 @@ export function RunsPage() { return () => window.clearTimeout(t); }, [detailEvent]); + useEffect(() => { + if (!detailEvent) return; + const drawer = drawerPanelRef.current; + if (!drawer) return; + + const selector = + 'a[href]:not([tabindex="-1"]), button:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]):not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), select:not([disabled]):not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])'; + + const focusables = (): HTMLElement[] => + Array.from(drawer.querySelectorAll(selector)).filter( + (el) => !el.hasAttribute("disabled") && el.tabIndex !== -1, + ); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + const nodes = focusables(); + if (nodes.length === 0) return; + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (active === first || !drawer.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else if (active === last) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [detailEvent]); + const runQuery = useCallback(async () => { setRawErr(null); + setRunsQueryError(null); setResult(null); const rid = releaseId.trim(); if (!rid) { @@ -135,8 +232,9 @@ export function RunsPage() { limit: lim, }); setResult(data); + setRunsQueryError(null); } catch (e) { - setRawErr(String(e)); + setRunsQueryError(classifyRunsFetchError(e)); } finally { setBusy(false); } @@ -204,8 +302,6 @@ export function RunsPage() { windowVal, ]); - const closeDrawer = useCallback(() => setDetailEvent(null), []); - const renderEventRow = useCallback( (rec: Record, idx: number, keyPrefix: string) => { const runId = typeof rec.run_id === "string" ? rec.run_id : ""; @@ -235,7 +331,10 @@ export function RunsPage() { type="button" className="fd-btn fd-btn--ghost fd-btn--sm" aria-haspopup="dialog" - onClick={() => setDetailEvent(rec)} + onClick={(e) => { + drawerReturnFocusRef.current = e.currentTarget; + setDetailEvent(rec); + }} > View @@ -352,6 +451,43 @@ export function RunsPage() { {rawErr ?

{rawErr}

: null} + {runsQueryError && !result ? ( +
+
+

Could not load runs

+
+
+

{runsQueryError.title}

+

+ {runsQueryError.kind === "network" ? ( + <> + This usually means the UI lost contact with the FlightDeck server (offline, wrong host, or CORS). + Confirm flightdeck serve is up and you are on the same + origin the UI expects. + + ) : runsQueryError.kind === "client" ? ( + <> + The server refused this query (validation, auth, or missing data). Adjust filters or release ID, or + check API credentials if your deployment requires them. + + ) : runsQueryError.kind === "server" ? ( + <>The server reported an internal error. Retry in a moment; if it persists, check server logs. + ) : ( + <>Something went wrong while loading run events. Details appear below. + )} +

+

+ {runsQueryError.detail} +

+
+ +
+
+
+ ) : null} + {result ? (
@@ -470,7 +606,7 @@ export function RunsPage() {
- ) : ( + ) : runsQueryError ? null : (

Choose a Release ID (datalist is filled from registered releases when the server is @@ -481,7 +617,7 @@ export function RunsPage() { )} {detailEvent ? ( -

+