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). Onlyadmin/*paths are proxied; client cookies and any client-suppliedX-Admin-Keyare dropped. - Production: Cloudflare Access sits in front of the Worker and adds
Cf-Access-Jwt-Assertion(and theCF_Authorizationcookie). 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,getSessionserver function). - Development: the proxy injects
X-Admin-Keyfrom the Worker secret.
Cloudflare Access setup
- Zero Trust → Access → Applications → Add a self-hosted application for the dashboard
hostname (e.g.
ops.example.com). Session duration 8–24 h. - 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). - Set
ACCESS_TEAM_DOMAINinwrangler.jsonc(<team>.cloudflareaccess.com) andBACKEND_URL. - Do not set
ADMIN_KEYin 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; typeart_…,usr_…,src_…to jump).- Review queue:
j/knext / previous,ppublish (or keep for audit/reported),rreject,eedit & 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 againstdaily_cost_cap_usd(warn ≥ 80 %, red at the cap). For acost_cappause 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_usdandmax_jobs_per_run(saved with the config, 422 inline), the same meter and banner; Pause recordspaused_reason: manual. - Run now: a 202 with
stoppedkeeps the dialog open and says how many of the planned jobs were queued before the guard paused generation; a409 generation_pausedshows the reason (and spend vs cap forcost_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:mockcan 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), elseaudio/webm;codecs=opus. Chrome (checked on Chromium 152) supportsaudio/mp4, but bareaudio/mp4records 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/audiois 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/timeoutfails its first attempt (Retry then succeeds),slowtakes ~15 s; an ASR model id containingfailfails transcription; a take under 5 s is unreliable;googleis "not configured" (no enabled chain entry), so it returns the 422; an ASR model id containingdriftfails a webm/mp3 round withaudio_too_longafter transcription. Like the backend, blind results are released together, lane ids are random, the first blind verdict is kept asblind_verdict, and once a run has a verdict its recorded rounds are locked (409verdict_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.