Dashboard

Dashboard data binding

Every visible data point to its admin API path and field.

Every visible data point → admin API (docs/api/openapi-admin.yaml, base /v1) path and field. The browser calls /api/proxy/<path>; the Worker adds auth and forwards to ${BACKEND_URL}/<path> (BACKEND_URL may be a bare origin; /v1 is appended). Every row below is verified against the real backend (backend/, PR #13) by scripts/integration.sh and a browser pass; the mock (pnpm dev:mock) serves the same shapes.

Errors (all screens): ErrorEnvelope.error.code picks an operator-facing text (src/i18n/en.ts → errors.codes, src/api/client.ts#describeError), with the server message as fallback; details fills in specifics (who decided, allowed transitions, spend vs cap…). 422 details.fields ({ <field path>: message }) is shown inline next to the field (fieldErrors(); config paths like thresholds.0.review_floor are mapped to the language's slider). Handled codes: 409 already_decided, illegal_decision, already_resolved, illegal_transition, generation_paused (+ details.paused_reason), deletion_in_progress, not_failed, round_busy, round1_not_assessed, not_unreliable, not_assessed, verdict_given (+ details.verdict_at); 410 audio_purged; 413 audio_too_large; 415 unsupported_media_type; 422 validation_failed, license_not_allowed, audio_too_long (+ details.duration_ms, max_ms; also as a round's error after transcription); 404 article_not_found, audio_unavailable; round_busy reads details.round, status, exemplar_pending; 401/403 → access denied state; 404 → not-found state; 5xx/network → retryable error state.

Local (non-API) state: review undo window (5 s, client only), mock scenario cookie, table selection, unsaved drafts, filter/tab state in the URL. Operator identity: decoded email claim of Cf-Access-Jwt-Assertion via the getSession server function (not an API call).

Global shell

Element Source
Nav badge "Review queue" GET /admin/overview → review.open
Nav badge "Reports" GET /admin/overview → reports.open
Nav badge "Sources" (red) GET /admin/overview → sources.failing
Operator email getSession() server fn → JWT payload email (prod), admin-key (dev), mock-operator@localhost (mock)
Environment pill getSession() → environment (MOCK, ACCESS_TEAM_DOMAIN vars)
User search navigates to /users/$id → GET /admin/users/{user_id}
Topic names / colors everywhere GET /admin/topics (id, color_token, names, glyphs); src/lib/topics.ts mirrors the backend seed while loading

1. Overview /

Auto-refresh: refetchInterval 60 s; "Updated x s ago" = TanStack Query dataUpdatedAt.

Element Path Field
Inventory heatmap cell value / color GET /admin/overview inventory[].published, .target (color = published/target, src/lib/heatmap.ts)
Cell tooltip same inventory[].in_pipeline, .unseen_available
"n of 30 cells below target", "n articles short" same derived from inventory[]
Cell click → /articles?language&topic&difficulty GET /admin/articles?language&topic&difficulty
Paused banner (reason pill + text) same pipeline.paused, pipeline.paused_reason (manual / cost_cap / provider_errors)
Banner → Resume GET + PUT /admin/generation/config whole config with paused: false, paused_reason: null
Banner hint "resuming re-pauses" (cost_cap) / "Raise the cap" / "Open AI providers" links same pipeline.spend_today_usd ≥ pipeline.daily_cost_cap_usd (src/lib/spend.ts)
Spend today vs cap meter (Cost card) same pipeline.spend_today_usd, pipeline.daily_cost_cap_usd (tone: ok < 80 % ≤ warn < 100 % ≤ bad)
Generated 24 h / 7 d same pipeline.generated_24h, pipeline.generated_7d
Auto-publish 7 d (vs 85 % target, ADR-002 D5) same pipeline.auto_publish_rate_7d
Failed jobs 24 h / Job queue same pipeline.failed_jobs_24h, pipeline.queue_depth
Review open / audit / reported same review.open, review.audit_open, review.reported_open
Median age same review.median_age_hours
Inflow 24 h (+ alarm > 20/day) same review.inflow_24h
Inflow sparkline (14 d) same review_inflow_daily[]
Cost per published article / 24 h / 7 d same cost.per_published_article_7d, cost.usd_24h, cost.usd_7d
Provider health table same providers[].capability, .active_model_ref, .error_rate_24h, .fallbacks_24h
Open reports / rights claims same reports.open, reports.rights_open
Sources enabled / failing same sources.enabled, sources.failing

2. Review queue /review

Element Path Field
Tabs + counts GET /admin/review?kind= counts{needs_review,audit,reported} (minus locally pending decisions)
List item title / lang / topic / tier / QA same items[].article.title, .language, .topic_id, .difficulty, .qa_score
Flag chips same items[].reasons[]: check ids (argument_clarity, audit_sample) or report reasons as report:<reason> (label via reasonLabel)
Waiting time same items[].created_at
Header pills same article.status, .length, .source.kind, .source.license
Source text (licensed only) vs draft same article.source_text, article.source.url, article.paragraphs[], article.reading_hint
Highlighted paragraphs same paragraphs containing a failed check's evidence
QA report: score, decision, threshold, safety, duplicate same article.quality_report.qa_score, .decision, .threshold_used, .safety_passed, .duplicate_of
QA checks list same quality_report.checks[] .id, .kind, .passed, .score, .detail, .evidence; .model_ref, .created_at
Key points editor same article.key_points[] (edits sent as edits.key_points with edit_and_publish)
Provenance same article.provenance.generator_model_ref, .prompt_version, .cost_usd, article.pair_id, article.source.attribution
Phone preview GET /admin/articles/{id}/preview public Article: title, byline, topic.name, est_read_seconds, paragraphs, reading_hint, source{name,url,license}
Publish / Keep / Edit & publish / Regenerate / Reject / Retire POST /admin/review/{item_id}/decide (sent after the 5 s undo window, always keepalive) decision, edits (ArticleDraftInput), reason (RejectReason), note; 409 already_decided → toast with details.decision/actor, queue refetched

3. Articles /articles, /articles/$id

Element Path Field
Table rows GET /admin/articles?status&language&topic&difficulty&source_kind&q&sort&cursor&limit=25 items[]: title, status, language, topic_id, difficulty, length{unit,value}, source.kind, source.license, qa_score, health.score, health.sessions, updated_at
Pagination same next_cursor (previous = client-side cursor stack)
Sort select same sort enum
Bulk retire POST /admin/articles/{id}/transition ×n {action:"retire", note} (skips statuses not in D3)
Detail header + actions GET /admin/articles/{id}; actions POST …/transition id, version, status, …; legal actions from ADR-002 D3 (src/lib/lifecycle.ts)
Content editor (title, paragraphs, reading hint, byline, key points + Σ weights) GET / PATCH /admin/articles/{id} title, paragraphs, reading_hint, byline, key_points[]
Save & publish now (override) PATCH /admin/articles/{id} publish_now: true, note (required in UI)
Details card GET /admin/articles/{id} source.*, provenance.*, pair_id
Preview tab (+ version select) GET /admin/articles/{id}/preview?version= public Article
Quality tab: QA report GET /admin/articles/{id} quality_report
Quality tab: health stats same health.score, .sessions, .reports, .completion_rate, .avg_key_info, .helpful_rate, .computed_at
Quality tab: health trend same health.trend[]
Versions list GET /admin/articles/{id}/versions items[].version, .created_at, .actor, .change_summary, .qa_score
Version diff same items[].snapshot
History tab GET /admin/audit-log?entity=article:{id} items[].at, .actor, .action, .before, .after, .note (entity = type:id, exact match); review decisions are logged on review_item:{id} and do not appear here

4. Sources /sources, /sources/$id

Element Path Field
Table: name, host, type, lang, license, schedule GET /admin/sources items[].name, .url, .type, .language, .license, .owner_cleared, .schedule_cron (label via src/lib/cron.ts)
Last run (+ error) same last_run.at, .status, .fetched, .new_items, .error
Yield / published / rejected same stats.yield, .articles_published, .articles_rejected
Enabled toggle (pausing confirms) PATCH /admin/sources/{id} {enabled}
License select (disallowed shown disabled), attribution preview, share-alike / attribution notes GET /admin/licenses items[].id, .name, .allowed, .attribution_template, .requires_attribution, .share_alike
Topic hint checkboxes GET /admin/topics id, names
Create / edit form (cron presets + custom) POST /admin/sources, PATCH /admin/sources/{id} SourceInput; 422 error.message/details inline
Fetch now (+ live job toast) POST /admin/sources/{id}/fetch then GET /admin/jobs/{id} polled Job.id, .status, .result.fetched, .result.new_items, .error
Detail header / config / stats GET /admin/sources/{id} all Source fields
Archive (confirm) DELETE /admin/sources/{id} 204
Items table (→ produced articles) GET /admin/sources/{id}/items?cursor= items[].title, .url, .author, .published_at, .retrieved_at, .text_length, .status, .article_ids; next_cursor

6. AI providers /providers

Element Path Field
Chain editor per capability (reorder, enable, model, timeout, options JSON; first enabled = active) GET / PUT /admin/ai-providers {asr,llm,content_llm,qa_llm,tts,embeddings}.chain[].provider, .model, .enabled, .timeout_ms, .options
Price table editor same price_table[].provider, .model, .input_per_1k, .output_per_1k, .per_minute
"Last saved … by …" same updated_at, updated_by
Usage by capability (calls, errors, error rate, fallbacks, p50, cost) + cost bars GET /admin/ai-providers/usage?range=24h|7d|30d by_capability[]
Usage by provider same by_provider[] .provider, .model, .capability, .calls, .errors, .cost_usd (merged by provider+model client-side)
Daily cost chart same daily[].date, .cost_usd
Cost per published article same cost_per_published_article_usd
Test prompt POST /admin/ai-providers/test request capability, input, language; response model_ref, latency_ms, cost_usd, output

5. Generation /generation

Element Path Field
Paused pill, Pause (confirms) GET / PUT /admin/generation/config paused: true (→ paused_reason: "manual")
Paused banner (reason + Resume) same paused, paused_reason; resume = PUT with paused: false
Spend guard: daily cost cap, max jobs per run (saved with the config; 422 inline) same daily_cost_cap_usd, max_jobs_per_run
Spend today vs cap meter GET /admin/overview pipeline.spend_today_usd (cap = the draft's daily_cost_cap_usd)
Prompt version, batch max, default target, retire health below same prompt_version, batch_max, default_target, retire_health_below
Audit sample rate slider (0–20 %) same audit_sample_rate (0–1)
Source mix slider same mix.ai_original
Pair generation toggle same pair_generation
"Last saved … by …" same updated_at, updated_by
Targets grid (blank = inherit default, ↺ = remove override) same targets[]{language,topic_id,difficulty,target}; placeholder default_target
"n pub" beside each cell GET /admin/overview inventory[].published
Threshold sliders per language /admin/generation/config thresholds[].auto_publish, .review_floor
Expected auto/review/reject share, review items/day, distribution histogram GET /admin/quality?range=30d&language= aggregate.qa_score_distribution[].buckets, aggregate.generated; math in src/lib/thresholds.ts
Save bar → confirm → save PUT /admin/generation/config whole GenerationConfig with the server's current paused/paused_reason; 422 details.fields inline (thresholds.<i>.review_floor → that language's slider)
Topic briefs table GET /admin/generation/briefs items[]: language, topic_id, title, angle, difficulty, enabled, uses, last_used_at, avg_qa_score
Create / edit / enable / delete brief POST / PATCH / DELETE /admin/generation/briefs[/{id}] TopicBriefInput
Run-now dialog POST /admin/generation/run mode, language, topic_id, difficulty, brief_id, source_item_id, count, pair → 202 jobs[]
Run-now: "stopped by the spend guard" result same 202 stopped.paused_reason, stopped.planned vs jobs.length
Run-now: paused error same 409 generation_paused + details.paused_reason, spend_today_usd, daily_cost_cap_usd; 422 details.fields inline per field
Jobs table (+ type/status filters, pager, live poll 5 s) GET /admin/jobs?type&status&cursor (opaque cursor) items[]: id, type, status, input, result, error, attempts, cost_usd, created_at, started_at, finished_at, actor; next_cursor
Retry POST /admin/jobs/{id}/retry returned Job

7. Quality /quality

Element Path Field
Range / language controls GET /admin/quality?range=7d|30d|90d&language= —
KPI row same aggregate.generated, .auto_published, .auto_publish_rate, .needs_review, .rejected, .median_queue_hours
Auto-publish rate trend (target 85 %) same aggregate.daily[]
Reject reasons bars same aggregate.reject_reasons[]
Failed checks bars same aggregate.failed_checks[]
Health histogram same aggregate.health_histogram[]
Audit agreement same aggregate.audits{sampled,agreed,overturned}
Helpfulness by article (→ /articles/$id) same aggregate.helpfulness_by_article[]
Threshold history same aggregate.threshold_history[]
ADR-002 D5 inflow hint same needs_review ÷ days > 20

8. Reports /reports

Element Path Field
Status tabs (open / resolved / all) GET /admin/reports?status= items[]
Columns same created_at, kind, reason, comment, article_id (→ /articles/$id), assessment_id, user_id (→ /users/$id), status, resolution
Resolve dialog: article status, "Decide in review queue" link GET /admin/reports + GET /admin/articles/{id} report review_item_id (falls back to the article's), article status, title
Resolve POST /admin/reports/{id}/resolve resolution, note; 409 already_resolved (+ details.resolution) → toast; 422 inline

9. Users /users, /users/$id

Element Path Field
Waitlist CSV export (confirm: contains emails) GET /admin/waitlist/export text/csv
Header / profile GET /admin/users/{user_id} display_name, user_id, auth_provider, ui_locale, practice_languages, created_at
Entitlement card same entitlement.status, .product_id, .expires_at, .will_renew, other keys verbatim
Activity same stats.completed_sessions, .streak, .level, .last_active_at
Open reports same open_reports
Deletion jobs (polls 3 s while running) same deletion_jobs[] (Job) incl. result.deleted.{sessions,attempts,audio_objects,transcripts,assessments}
Delete training data (typed confirmation = user id) POST /admin/users/{user_id}/delete-data returned Job.id; 409 deletion_in_progress → toast

10. Audit log /audit

Element Path Field
Actor / entity filters (in URL) GET /admin/audit-log?actor&entity&cursor —
Rows same items[]: at, actor, action, entity_type, entity_id (links for article/source/user), note
Change diff same before, after (changed keys only)
Pager same next_cursor

11. Playground /playground, /playground/$id (ADR-002 D4, issue #33)

Polling: the run polls every 2 s while status == "processing" or any round is transcribing/assessing, any result is pending/running or any exemplar is pending (src/lib/playground.ts#isRunBusy); the history polls every 5 s while a row is processing. Mutations that return a PlaygroundRun write it straight into the cache. Lanes are always shown in label order (lanesByLabel): lane ids are opaque and random. While blind, the API releases a round's results together (every lane running with nothing inside, finished_at null), so the lanes read "Waiting for all lanes" instead of per-lane progress. Playground cost appears only on the run; overview and provider usage never include it.

The recording is local state until it is submitted: MediaRecorder (audio/mp4;codecs="mp4a.40.2" when supported, else audio/webm;codecs=opus, src/lib/playground.ts#pickRecordingType) or a chosen file; the timer, pause, level meter (Web Audio analyser), auto-stop at 3:00 / 2:00 (ROUND_CAP_MS, ADR-001 D2) and the playback before submit are client only. Submitting is the one binary call: the browser PUTs the blob, and the proxy passes the bytes through (src/server/proxy.ts, audio/* only, ≤ 5 MB enforced while reading, sent upstream as a buffer); every other call is JSON.

Element Path Field
History rows (newest first), language filter, pager GET /admin/playground/runs?language&cursor items[]: article_title, id, article_id, article_version, language, lane_count, rounds_done, status, blind/revealed (mode pill), verdict.preferred_lane_id ("tie" → Tie, else Winner picked) marked "picked blind" / "changed after reveal" / "after reveal" from blind_verdict (null, same lane, other lane), created_by, created_at; next_cursor
New run: article picker GET /admin/articles?status=published&language&q&limit=8 items[]: id, title, version, language → article_id, article_version
New run: previous action — previous_action (MicroSkillId or null), labels m.playground.microSkills
New run: ASR model (default first enabled) GET /admin/ai-providers asr.chain[] → asr (provider, model, timeout_ms, options)
New run: lanes (prefilled, 2–6) GET /admin/ai-providers enabled llm.chain[] entries → lanes[] (provider, model, options as JSON)
New run: blind toggle, note — blind (default true), note
Create POST /admin/playground/runs 201 → navigate to /playground/{id}; 422 details.fields inline (lanes, lanes[i].provider/.model/.options/.timeout_ms, asr.*, previous_action, note); 404 article_not_found under the picker
Run header GET /admin/playground/runs/{run_id} article.title, id, created_by, created_at, status, article.language, blind + revealed_at (mode pill), asr.model_ref, previous_action, cost_usd (revealed only; null → "unpriced"), note
Article to read same article.id, .version, .title, .paragraphs[], .reading_hint
Round tabs (dot = status) same rounds[].round, .status; round 2 opens when a lane has a done round-1 result
Recording meta same rounds[].duration_ms, .content_type, .duration_source (container the file, client the browser, client_checked checked against the transcript), .uploaded_at, .audio_available
Playback (recording) GET /admin/playground/runs/{run_id}/rounds/{round}/audio url (refetched 30 s before expires_at, and when the player fails to load it); 410 audio_purged (or audio_available false with uploaded_at set) → "removed after 30 days"; 404 audio_unavailable → "no recording yet"
Upload / replace recording PUT /admin/playground/runs/{run_id}/rounds/{round}/audio binary body, Content-Type, X-Client-Duration-Ms (always sent; the server ignores it for MP4/WAV); 202 → run; 409 round_busy / round1_not_assessed, 413, 415, 422 audio_too_long inline under the take. Replace is disabled while that round (round 1: also round 2) is transcribing, assessing or has a model answer pending; replacing round 1 when round 2 exists asks first (it resets round 2). Once the run has a verdict, a round with uploaded_at is locked (409 verdict_given): no Replace, a note "this run has a verdict; start a new run to try again" and the shortcut below; the first round-2 upload stays open
New run with the same article and lanes (header once there is a verdict, and in the lock note) GET /admin/playground/runs/{run_id} → /playground?new=true&from={run_id} prefills article_id/article_version (article.*), previous_action, asr (asr.model_ref, added to the choices if the chain no longer has it), lanes[] (lanes[].model_ref split at the first /, options) in label order; blind back on
Transcribing / failed banner run rounds[].status (transcribing, failed + rounds[].error.code/.message; audio_too_long with details.duration_ms/max_ms from the post-transcription check)
Unreliable banner + Assess anyway run; POST …/rounds/{round}/assess-anyway rounds[].unreliable_reason (labels m.playground.unreliableReasons), .assessed_anyway; 409 not_unreliable → toast
Shared transcript (sticky), seek on timestamps run rounds[].transcript.segments[] (start_ms, end_ms, text), .asr_ref, .speech_ratio, .length
Evidence highlight run the clicked lane's assessment.evidence[] (start_ms/end_ms → overlapping segments, transcript_excerpt → <mark>)
Lane header run lanes[].label, .model_ref (null → "Model hidden"), .options, result status, winner = verdict.preferred_lane_id
Lane: overall, band, confidence, summary run lanes[].results[round].assessment: overall, band_label (fallback band), confidence, confidence_note, summary
Lane: dimensions same dimensions.{key_info,structure,efficiency,fluency}: score, note, evidence_ids[], insufficient_evidence
Lane: key points same key_point_coverage[]: text, covered, evidence_id
Lane: one action same one_action: micro_skill_id, instruction, example_sentence, evidence_id
Lane: previous action same previous_action, achieved_previous_action
Lane: evidence list same evidence[]: id, kind, start_ms, end_ms, transcript_excerpt, note
Lane: round-2 comparison same comparison: verdict, verdict_label, overall_delta, key_info_delta, structure_delta, efficiency_delta, fluency_delta, duration_delta_ms, key_points_lost[]
Lane: rubric same rubric_version
Lane: latency / tokens / cost (revealed only) same latency_ms, usage.input_tokens/.output_tokens, cost_usd
Lane: queued / scoring (revealed) or "waiting for all lanes" (blind) / skipped / failed + Retry same; POST …/lanes/{lane_id}/retry {round} result status; error.details.kind (fallback error.code); error.message only once revealed (a provider message can name the model); 409 not_failed → toast
Lane: Score round 2 POST …/lanes/{lane_id}/retry {round: 2} shown when the lane's round-2 result is skipped, round 2 isn't unreliable and the lane's round-1 result is done
Lane: model answer POST …/lanes/{lane_id}/exemplar {round} result exemplar: status, label, text, audio_url (an expired URL refetches the run), tts_ref; 409 not_assessed → toast
Verdict bar POST /admin/playground/runs/{run_id}/verdict preferred_lane_id (lane id or "tie"), note; shows verdict.preferred_lane_id (→ lane label), .blind, .decided_by, .decided_at, .note; when blind_verdict names another lane it is shown first ("Blind pick B → now Tie") and that lane gets a "Blind pick" pill; 422 details.fields.preferred_lane_id inline
Reveal models POST /admin/playground/runs/{run_id}/reveal → revealed_at, then model_ref, options, latency_ms, usage, cost_usd appear
Delete run DELETE /admin/playground/runs/{run_id} 204 → back to history

Local states: mic permission denied / no microphone / MediaRecorder unavailable (upload only), offline (submit disabled, take kept), unsent take (leave warning).

Mock (src/mock/playground.ts): six seeded runs, one per state (pg_seed_ready, pg_seed_blind, pg_seed_attention, pg_seed_unreliable, pg_seed_new, pg_seed_old with purged audio and a blind pick changed after the reveal); lane ids are random; WAV duration comes from the RIFF header; blind results are released together; progress is simulated (transcription ~2 s, each lane 1.5–6.5 s). Transcripts, assessments and model answers are invented from the article text (mock data). Triggers: a lane model id containing fail / timeout fails its first attempt; slow takes 12–16 s; an ASR model id containing fail fails transcription; drift makes a webm/mp3 transcript run past the client duration (post-transcription audio_too_long); a take under 5 s is too_short; a provider without an enabled chain entry (google in the fixtures) is "not configured" (422).

Contract change requests

CR-1..19 from the first dashboard PR were applied to docs/api/openapi-admin.yaml (#12) and are implemented by the backend (#13); the UI now reads them directly. CR-13 (mock default target 3 vs real 20) stays mock-only by design.

New requests found while integrating (none change the contract in this PR):

# Request Why
CR-20 List 409 illegal_decision (review decide) and 409 not_failed (job retry) in the spec's error-code summary The backend returns both; the dashboard handles them
CR-21 Document ReviewItem.reasons for reported items as report:<reason> (or emit the bare reason) Spec says "check ids or report reasons"; backend sends report:rights
CR-22 cost.per_published_article_7d / cost_per_published_article_usd should be null when no call was priced Backend returns 0 without a price table, which reads as "free"