Dashboard

dashboard

Operator dashboard for content operations (ADR-002 D4): inventory, review queue, articles, sources, generation, AI providers, quality, reports, users and the audit log. It talks only to the admin API (docs/api/openapi-admin.yaml) through its own server-side proxy.

Stack: TanStack Start (React 19, Vite, TypeScript strict, file routes) + Tailwind v4, TanStack Query (data) and TanStack Table (lists), deployed to Cloudflare Workers with @cloudflare/vite-plugin. Charts are small inline SVG components (src/components/charts.tsx), no chart library.

Quick start (mock mode, no backend needed)

cd dashboard
pnpm install
pnpm dev:mock          # http://localhost:3100 — every admin endpoint served from src/mock/

Mock mode (CLOUDFLARE_ENV=mock, MOCK=1) serves the whole admin contract from an in-memory fixture store: 70+ bilingual articles (en / zh-Hans × 5 topics × 3 tiers, every status), 4 sources with items, 20 jobs (simulated to run/finish), a review queue of 12 (needs_review, audit, reported), provider usage with daily series, quality aggregates, 6 reports, users and an audit log. Mutations (transitions, review decisions, config saves, source edits, job retries…) persist in memory until the dev server restarts and are written to the audit log as mock-operator@localhost.

The top bar has a Mock scenario switch (mock only) to see every state on every screen: Empty lists, Backend error (retryable 503), Permission denied (403), Slow (1.5 s). It sets a mock_scenario cookie read by the proxy.

Develop against a real backend

# terminal 1 — backend (mock AI providers, no keys needed)
cd backend && pnpm install && cp .dev.vars.example .dev.vars && pnpm seed
pnpm exec wrangler dev --port 8787 --var ENVIRONMENT:development --var AI_DEFAULT_CHAIN:mock --var CONTENT_FETCH_ENABLED:true
pnpm content:run -- --mode fill_gaps --count 4 --pair      # some content; `pnpm smoke` adds sources, review items…

# terminal 2 — dashboard, real mode
cd dashboard
cp .dev.vars.example .dev.vars   # ADMIN_KEY must equal the backend's (dev-admin-key)
pnpm dev                         # http://localhost:3100 → BACKEND_URL (wrangler.jsonc: http://localhost:8787/v1)

.dev.vars overrides wrangler.jsonc vars, so BACKEND_URL=http://localhost:8787 there points the proxy elsewhere (a bare origin gets /v1 appended). Shell environment variables are not read while a .dev.vars exists.

Integration smoke (real backend, no browser)

pnpm integration            # = scripts/integration.sh
KEEP=1 pnpm integration     # …and leave both servers running with the populated data (backend :8788, dashboard :3298)

Boots the backend on a throw-away local state (--persist-to a temp dir: migrations + seed), populates it through the admin API (fill_gaps + pairs, weak / low / unsafe briefs → review queue and rejects, an audit run, a fixture RSS source → fetch → licensed adaptation, a reader whose rights report unpublishes an article, a mock price table so costs are non-zero), starts the dashboard in real mode (wrangler env integration, temporary .dev.vars.integration) and curls every screen plus ~95 /api/proxy/admin/* calls asserting status codes and key fields with jq: reads with pagination cursors and filters, review decisions incl. 409 already_decided, 409 illegal_transition, edits → new version, sources 422 license_not_allowed / details.fields, briefs CRUD, config 422 details.fields, the spend guard (manual pause → 409 generation_paused; cap at today's spend → 409 with paused_reason: cost_cap → resume), job retry (409 not_failed), providers save + test prompt, report resolve (409 already_resolved), delete-data (409 deletion_in_progress). Needs jq, python3 (fixture server) and free ports 8788 / 3298 / 8796 (override with BACKEND_PORT, DASH_PORT, FIX_PORT).

Environment

Var Where Meaning
BACKEND_URL wrangler.jsonc vars Backend base incl. version, e.g. https://api.example.com/v1. /api/proxy/admin/x → ${BACKEND_URL}/admin/x.
ADMIN_KEY .dev.vars (secret) Dev only. Injected as X-Admin-Key by the proxy; never sent to the browser. Not used when ACCESS_TEAM_DOMAIN is set.
ACCESS_TEAM_DOMAIN vars e.g. shuke.cloudflareaccess.com. When set (production) the proxy requires the Access assertion and forwards it; it never injects ADMIN_KEY.
MOCK vars (env.mock) "1" serves the fixture store instead of calling the backend.

How auth works

  • The browser only ever calls the dashboard's own /api/proxy/* server route (src/routes/api/proxy/$.ts → src/server/proxy.ts). Only admin/* paths are proxied; client cookies and any client-supplied X-Admin-Key are dropped.
  • Production: Cloudflare Access sits in front of the Worker and adds Cf-Access-Jwt-Assertion (and the CF_Authorization cookie). The proxy forwards the assertion to the backend, which verifies it against the team's public keys. The dashboard only decodes the JWT payload to show the operator email in the top bar (src/server/auth.ts, getSession server function).
  • Development: the proxy injects X-Admin-Key from the Worker secret.

Cloudflare Access setup

  1. Zero Trust → Access → Applications → Add a self-hosted application for the dashboard hostname (e.g. ops.example.com). Session duration 8–24 h.
  2. Policy: Allow emails / an IdP group of operators. Add a second application (or the same one) covering the backend's /v1/admin/* path so the backend can verify the same assertion (or configure the backend with the dashboard application's AUD tag).
  3. Set ACCESS_TEAM_DOMAIN in wrangler.jsonc (<team>.cloudflareaccess.com) and BACKEND_URL.
  4. Do not set ADMIN_KEY in production.

Deploy

pnpm wrangler login
pnpm run deploy        # vite build + wrangler deploy (Worker "shuke-dashboard")
pnpm run deploy:demo   # optional: mock-mode demo Worker "shuke-dashboard-mock" (put Access in front of it too)

Uncomment routes in wrangler.jsonc for a custom domain.

Quality gates

pnpm typecheck   # wrangler types + tsc --noEmit
pnpm lint        # oxlint (typescript-eslint does not support TypeScript 7 yet)
pnpm test        # vitest: proxy auth injection + audio passthrough, heatmap/threshold math, diff, mock store (incl. playground)
pnpm build       # vite build (client + worker)
pnpm smoke       # boots dev:mock on :3199, curls every route and a set of proxy endpoints
pnpm integration # boots backend + dashboard (real mode), populates content, asserts ~95 proxy calls (see above)
pnpm screenshots # (dev server running) Playwright screenshots → docs/screenshots/

CI: .github/workflows/dashboard.yml runs all of the above except screenshots on PRs touching dashboard/.

Layout

Path What
src/routes/ Screens (file routes) and api/proxy/$, api/health server routes
src/api/ types.ts (contract mirror), client.ts (fetch + ApiError), hooks.ts (TanStack Query)
src/components/ Shell, command palette (⌘K), tables, dialogs (confirm / typed confirm), toasts (undo), QA report, phone preview, charts
src/mock/ Fixture corpus, deterministic fixture builder, in-memory store (playground in playground.ts), route handler
src/server/ Proxy + auth (pure, unit-tested), session server function
src/i18n/en.ts Every UI string, keyed by screen (add zh-Hans as a sibling catalog)
DATA-BINDING.md Every screen element → admin API path/field, and contract change requests

Keyboard

  • ⌘K / Ctrl+K — command palette (screens; type art_…, usr_…, src_… to jump).
  • Review queue: j / k next / previous, p publish (or keep for audit/reported), r reject, e edit & publish. Every decision has a 5 s Undo toast; the API call is sent when the toast expires (or immediately if the tab is closed).

Dark mode follows prefers-color-scheme. Layout is designed for ≥ 1024 px; on a phone the shell collapses and screens are readable (overview is the intended phone use).

Spend guard (ADR-002 D2)

  • Overview: a paused banner names the reason (manual, cost_cap, provider_errors) with Resume; the Cost card shows today's spend against daily_cost_cap_usd (warn ≥ 80 %, red at the cap). For a cost_cap pause with spend still ≥ cap the banner warns that resuming alone re-pauses on the next job and links to the cap field.
  • Generation → Pipeline control: daily_cost_cap_usd and max_jobs_per_run (saved with the config, 422 inline), the same meter and banner; Pause records paused_reason: manual.
  • Run now: a 202 with stopped keeps the dialog open and says how many of the planned jobs were queued before the guard paused generation; a 409 generation_paused shows the reason (and spend vs cap for cost_cap).
  • Mock: the fixture store implements the same guard (seeded spend $6.42 + finished job costs; $0.03 estimate per queued job), so pnpm dev:mock can show every state — lower the cap below today's spend and run.

Playground (ADR-002 D4)

/playground runs the app's loop (read → retell → feedback → shorter retell) on one recording and scores it with 2–6 pinned llm models side by side; one ASR model makes the one shared transcript. Blind by default: lanes are A, B, C… and model names, latency, tokens and cost appear after a verdict (or Reveal). Runs never touch users or app stats.

  • Recorder: MediaRecorder, preferring audio/mp4;codecs="mp4a.40.2" (AAC), else audio/webm;codecs=opus. Chrome (checked on Chromium 152) supports audio/mp4, but bare audio/mp4 records Opus in MP4, so AAC is asked for by name and bare MP4 comes after WebM. Timer, pause, level meter, auto-stop at 3:00 (round 1) / 2:00 (round 2), playback before submit; or upload a file (MP4/M4A, WebM, MP3, WAV, ≤ 5 MB). Mic denied, no microphone and no MediaRecorder fall back to upload; offline keeps the take until the connection is back. Auto-stop fires 0.3 s before the cap and the take reports its real length (a take still over the cap can't be submitted). Leaving with an unsent take asks first, for in-app navigation (router blocker, incl. the Round tabs) and for reload/close.
  • Proxy: PUT admin/playground/runs/:id/rounds/:n/audio is the one binary route: the body is passed through as bytes (audio/* only, 415 otherwise; 413 over 5 MB by Content-Length up front or while reading). It is read with the cap enforced and sent upstream as a buffer, so the service-binding → fetch fallback can replay it and the backend gets a real Content-Length. Every other body stays text, 1 MB.
  • Mock: six seeded runs (one per state) and simulated progress. A lane model id containing fail / timeout fails its first attempt (Retry then succeeds), slow takes ~15 s; an ASR model id containing fail fails transcription; a take under 5 s is unreliable; google is "not configured" (no enabled chain entry), so it returns the 422; an ASR model id containing drift fails a webm/mp3 round with audio_too_long after transcription. Like the backend, blind results are released together, lane ids are random, the first blind verdict is kept as blind_verdict, and once a run has a verdict its recorded rounds are locked (409 verdict_given); "New run with the same article and lanes" opens New run prefilled from that run.

Screenshots

Captured from real backend data (KEEP=1 pnpm integration, then node scripts/screenshots.mjs http://localhost:3298; 1440×960 @2x) — docs/screenshots/: overview (light/dark), overview + generation while paused by the cost cap (… overview-paused generation-paused after lowering the cap below today's spend and running), review, review-reported, articles, article detail + versions, sources, source detail, generation, providers, quality, reports, users, audit. Content is the backend's deterministic mock AI output. The Playground screens (playground-*) are captured in mock mode (pnpm dev:mock, then node scripts/screenshots.mjs http://localhost:3100 docs/screenshots playground playground-new …) until the backend playground is merged; the names are listed in scripts/screenshots.mjs.