---
title: "Dashboard"
source: "dashboard/README.md"
---

> Part of [Dashboard](/dashboard/llms.txt). Every page: [llms.txt](/llms.txt).
> View: [Operator dashboard (dash.clarisay.com, behind Cloudflare Access)](https://dash.clarisay.com)

# 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)

```bash
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

```bash
# 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)

```bash
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

```bash
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

```bash
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`.
