think
16px
820px

AHU AI Observatory — Dashboard UI (P1 plan 2) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to execute task-by-task. Each frontend task's implementer MUST invoke dataviz before writing any chart code and ui-ux-pro-max:ui-ux-pro-max for layout/component work. Steps use checkbox (- [ ]) syntax.

Goal: A role-aware web dashboard for ahu-ai-observatory, served on ai-ahu, that turns the live audit stream into readable BI (executive), a searchable call explorer (operator), and gated body/chain/access views (auditor) — consuming ONLY the existing :8300 query API. Real data is already flowing (chatbot traffic since 2026-07-04).

Architecture: React + Vite + TypeScript + Tailwind + shadcn/ui SPA (matches the OCR/chatbot frontends). Charts hand-built to the dataviz skill's specs (Recharts is permitted as the rendering primitive, but palette/marks/tooltips/legend follow dataviz, not Recharts defaults). Served by an nginx container that also reverse-proxies /api/* and /healthz to the observatory (ahu-observatory-observatory-1:8300) so the browser is same-origin — no CORS, and no observatory backend change. The bearer token is entered at a login gate, held in sessionStorage, and forwarded by the browser on same-origin API calls (nginx passes Authorization through).

Tech Stack: Vite 6 · React 19 · TypeScript · Tailwind v4 · shadcn/ui · TanStack Query v5 (server state + keyset pagination) · Recharts (chart rendering primitive only) · nginx:alpine (serve + proxy). No state lib beyond TanStack Query + React context for auth.

Global Constraints

  • Repo: new ahu-observatory-dashboard/ under /home/efran/remote-development/poc-ahu-ai/ (its own git repo, branch feat/dashboard-p1 — Task 1 creates it). NOT a subdir of the Go repo.
  • Consumes ONLY the observatory :8300 API as built (do not assume endpoints that don't exist). Authoritative surface — verify against ../ahu-ai-observatory/internal/api/server.go:
  • GET /api/summary?from&to → totals by engine+status (executive+)
  • GET /api/series?from&to&bucket&group_by → time buckets; group_by ∈ {engine,upstream,traffic_class}; fields include calls, tokens_in, tokens_out, avg_total_ms, max_total_ms, avg_queue_ms (executive+)
  • GET /api/calls?engine&upstream&status&trace_id&from&to&limit&before_ts&before_id → metadata list, keyset paginated (operator+)
  • GET /api/calls/{event_id} → one call's metadata (operator+)
  • GET /api/calls/{event_id}/body{event_id, request, response} decompressed; every call is logged server-side (auditor only)
  • GET /api/verify-chain?from&to{ok, bad_chain_pos} (auditor only)
  • GET /api/access-log?limit → body-access audit rows (auditor only)
  • GET /healthz (open); error shape is {"error":{"code","message"}}.
  • Roles (allow-sets, exactly): executive → summary+series; operator → +calls+call-detail; auditor → +body+verify-chain+access-log. The UI hides/disables what the token's role can't reach AND handles a 403 gracefully (the server is the real gate; the UI is convenience).
  • dataviz non-negotiables are binding (each chart task's reviewer checks them): categorical palette in fixed order never cycled (values below); one y-axis per chart (never dual-axis); sequential = one hue light→dark; legend present for ≥2 series, ≤4 also direct-labeled; recessive grid/axes; text in ink tokens never series color; a table-view toggle on every chart (also satisfies the low-contrast relief rule); crosshair+tooltip on time-series, per-mark hover on bars; light+dark both selected and validated.
  • Palette (the dataviz reference instance — validated, use verbatim). Categorical light/dark by slot: 1 blue #2a78d6/#3987e5, 2 aqua #1baf7a/#199e70, 3 yellow #eda100/#c98500, 4 green #008300/#008300, 5 violet #4a3aa7/#9085e9, 6 red #e34948/#e66767, 7 magenta #e87ba4/#d55181, 8 orange #eb6834/#d95926. Status colors reserved (good/warn/serious/critical) — never reused as a series hue. Sequential blue ramp for magnitude. Each chart task runs scripts/validate_palette.js (in the dataviz skill dir) on the exact hues it uses, both modes, before commit.
  • No secrets in the repo. The dashboard has no tokens of its own; the user pastes one at login.
  • Every created/modified .md uploaded: curl -F "file=@<file>" https://x056.think.val.id/upload.
  • The Go observatory repo and all engine repos are read-only context — never modified.
  • Commit per task; messages end with Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT. Tests: pnpm test (Vitest) + pnpm build must pass before each commit; pnpm lint clean.

Explicitly OUT of scope (do not build; note as future)

  • Live GPU utilization, queue depth, replica health, autoscaler state — these come from the gateway controller (P3, not built). This dashboard is the BI/audit surface. A later plan adds the ops/fleet console once the controller exposes that telemetry; leave a clearly-labeled "Fleet health — available when GPU cluster management (P3) ships" placeholder card on the operator view, nothing more.
  • p50/p95/p99 latency — the API serves avg + max only. Use avg/max; put a one-line note that true percentiles need an observatory API addition (deferred backlog). Do NOT compute percentiles client-side over raw rows.
  • Alerting, report export/PDF, saved views — later.

File Structure

index.html · vite.config.ts (proxy /api:8300 for `pnpm dev`) · tailwind + shadcn config
src/main.tsx · src/App.tsx (router + role-gated routes)
src/lib/api.ts        typed fetch client (Authorization from auth ctx; parses {error} shape)
src/lib/auth.tsx      AuthProvider: token in sessionStorage, decodes role from a /api/whoami probe OR a role picked at login (see Task 2), logout
src/lib/viz/palette.ts  the categorical/sequential/status tokens above as CSS vars + TS constants
src/components/viz/    StatTile.tsx, TimeSeriesChart.tsx, BarBreakdown.tsx, ChartFrame.tsx (title+legend+table-toggle+dark-aware)
src/components/app-shell.tsx  nav (role-aware), theme toggle, health pill
src/views/ExecutiveView.tsx · OperatorView.tsx · AuditorView.tsx
src/views/LoginGate.tsx
deploy/nginx.conf (serve dist + proxy /api,/healthz  observatory) · deploy/compose.yaml · Dockerfile

Task 1: Scaffold + palette + API client + nginx proxy shell

Deliverable: pnpm dev runs an empty-but-styled shell; pnpm build produces dist/; the Docker image serves it and proxies /api to the observatory; palette tokens exist as CSS vars + TS constants; a typed api.ts client with an injectable token and {error}-shape parsing; one passing test (api client parses an error body).

  • [ ] Create repo + branch; pnpm create vite@latest . --template react-ts; add Tailwind v4, shadcn/ui init, TanStack Query, Recharts, Vitest.
  • [ ] src/lib/viz/palette.ts: the 8 categorical light/dark hues (fixed order), sequential blue ramp, reserved status colors, surface + ink tokens for both modes — as documented in Global Constraints. Export both a CSS-var stylesheet (.viz-root, @media (prefers-color-scheme: dark), and :root[data-theme=dark]/[data-theme=light] overrides so the theme toggle wins both ways) and typed TS constants.
  • [ ] Run the dataviz validator on the 8 categorical hues, --mode light and --mode dark (surfaces from the palette). Paste PASS output into the report. Fix nothing (reference palette passes) — this proves the wiring.
  • [ ] src/lib/api.ts: createClient(getToken) returning typed methods for every endpoint in Global Constraints; sets Authorization: Bearer <token>; on non-2xx parses {error:{code,message}} and throws a typed ApiError{status,code,message}; 401→ApiError with a flag the auth layer catches. Failing test first: given a stub 404 {"error":{"code":"UNKNOWN_MODEL",...}}, the client throws ApiError with .code==="UNKNOWN_MODEL".
  • [ ] vite.config.ts: dev proxy /api and /healthzhttp://192.168.83.20:8300 (so pnpm dev works against live data with a token).
  • [ ] deploy/nginx.conf: serve /usr/share/nginx/html, SPA fallback to index.html, and location /api/ { proxy_pass http://observatory:8300; proxy_set_header Authorization $http_authorization; } (+ same for /healthz). Dockerfile: node build stage → nginx:alpine. deploy/compose.yaml: dashboard service on the ahu-platform_default external network (to resolve the observatory container), published on a free host port (propose 8320; Task 7 verifies it's free on ai-ahu).
  • [ ] pnpm build + pnpm test green; commit feat(scaffold): vite+tailwind+shadcn shell, viz palette, typed api client, nginx proxy.

Interfaces produced: ApiClient type (all endpoint methods + return types), palette CSS-var names + TS constants, ApiError.


Task 2: Auth gate + app shell (role-aware nav, theme toggle, health pill)

Deliverable: Visiting the app with no token shows a login gate (paste token + pick role — see note); with a token, the app shell renders role-appropriate nav, a light/dark toggle (persisted), and a live /healthz status pill. A 401 anywhere returns the user to the gate.

  • [ ] Role determination: the API has no /whoami. Simplest honest approach: the login gate has the user paste their token AND select their role (executive/operator/auditor) from a dropdown — the selected role only drives UI affordances; the server enforces the real gate, so a wrong pick just yields 403s the UI surfaces. Store {token, role} in sessionStorage via AuthProvider. (Document this; a future /whoami endpoint would remove the manual pick.)
  • [ ] AuthProvider + useAuth(): token/role, login, logout, and an axios/fetch interceptor-equivalent that on ApiError.status===401 clears auth and routes to the gate.
  • [ ] App shell (ui-ux-pro-max for layout): left/top nav with only the sections the role allows (executive: Overview; operator: +Calls; auditor: +Audit); theme toggle stamping data-theme on <html> (persisted to localStorage), health pill polling /healthz every 15s (green ok / red unreachable, with the pending/ingested numbers in a tooltip).
  • [ ] Tests: role→visible-nav-items mapping; 401 clears session. Commit feat(shell): token/role auth gate, role-aware nav, theme toggle, health pill.

Task 3: dataviz-compliant chart primitives

Deliverable: Three reusable, dark-aware, accessible chart components wrapped in a common ChartFrame (title, legend, table-view toggle, empty/loading states). Implementer MUST invoke dataviz first and build to its mark/interaction specs, not Recharts defaults.

  • [ ] ChartFrame: title, optional subtitle, legend (present for ≥2 series, positioned once), a table-view toggle that swaps the chart for an accessible <table> of the same data (satisfies the relief rule for low-contrast slots + accessibility), loading skeleton, empty state.
  • [ ] StatTile: hero number + label + optional delta + optional sparkline; ink tokens for text; no legend. For headline figures (total calls, error rate, external-call count).
  • [ ] TimeSeriesChart: line/area over time, one y-axis, thin 2px lines, ≥8px markers only on hover, crosshair + tooltip showing all series at the hovered bucket, ≤4 series direct-labeled at line-end else legend, recessive grid. Series colored by categorical slot in fixed order keyed to the entity (engine/status), never repainted when the set filters.
  • [ ] BarBreakdown: horizontal bars for magnitude-by-category (calls by engine/model), 4px rounded data-end at baseline, 2px surface gap between bars, per-bar hover tooltip, direct value labels.
  • [ ] Run validate_palette.js on the exact hues these use (both modes); paste PASS. Tests: table-toggle renders equivalent data; series→fixed-slot color mapping is stable across a changing series set (the anti-pattern guard). Commit feat(viz): ChartFrame + StatTile + TimeSeries + BarBreakdown per dataviz specs.

Interfaces produced: the three chart component props + ChartFrame.


Task 4: Executive view

Deliverable: The default landing view for all roles. A date-range control (filters row, one row above charts) driving: headline stat tiles, throughput/latency time-series, and per-engine/model breakdowns — all from /api/summary + /api/series.

  • [ ] Filters row: date-range picker (default last 24h; presets 1h/24h/7d) + bucket auto-derived from range (1m/1h). Drives all panels via TanStack Query keys.
  • [ ] Stat tiles: total calls, overall error rate (from summary status split), total tokens (in+out), and the external-calls proof tile — count of calls with upstream_class="external_dev" (from a group_by-less summary or a series sum); in prod this should read 0, rendered with a good/critical status treatment. (If summary doesn't expose upstream_class, use /api/series?group_by=upstream and map known external upstream ids — document which.)
  • [ ] Time-series (TimeSeriesChart): calls/min by status (ok vs error, fixed slots); tokens/min (in vs out); avg + max latency (avg_total_ms,max_total_ms) — note in the panel subtitle that these are avg/max; p95/p99 pending an API addition.
  • [ ] Breakdown (BarBreakdown): calls by engine, calls by model (from group_by=engine/upstream series summed over the range).
  • [ ] Everything degrades cleanly for executive-only tokens (only summary/series are needed here — no 403s expected). Loading/empty/error states via ChartFrame. Test: date-range change refetches with new keys; external-calls tile shows 0 vs non-0 treatment. Commit feat(executive): summary tiles, throughput/latency series, engine/model breakdowns.

Task 5: Operator view — call explorer

Deliverable: A filterable, keyset-paginated table of individual AI calls with a detail drawer, from /api/calls + /api/calls/{id}.

  • [ ] Filters row: engine, upstream, status, trace_id, date-range → /api/calls params. Results table: ts, engine, surface, model, status (status-colored chip + label, never color-alone), traffic_class, queue_ms, total_ms, tokens. Recessive, dense, sortable-by-ts (server order).
  • [ ] Keyset pagination using before_ts+before_id (the API's cursor) — "load more" appends; never offset. TanStack Query useInfiniteQuery.
  • [ ] Row click → detail drawer: full metadata for that call (/api/calls/{id}), including trace_id with a "show all calls in this trace" action (filters the table by trace_id — the fan-out view). A "View bodies" button that is present only for auditor role and links into the auditor view for that event_id (operator can't fetch bodies — don't show a dead button).
  • [ ] "Fleet health" placeholder card labeled "Available when GPU cluster management (P3) ships" — static, no fake data.
  • [ ] Tests: filter change resets pagination; cursor advances without dup/skip across two pages (mock API). Commit feat(operator): keyset-paginated call explorer with detail drawer and trace fan-out.

Task 6: Auditor view — body viewer, chain verify, access log

Deliverable: The compliance surface. Body inspection (server-logged), hash-chain verification, and the access-log viewer — all auditor-gated, all handling 403 for lesser roles.

  • [ ] Body viewer: given an event_id (deep-linked from operator, or pasted), fetch /api/calls/{id}/body → show decompressed request + response in readable panels (JSON-pretty when parseable, raw otherwise), with a prominent "this view is recorded" notice (it is — every fetch logs an access row). Truncation note when the metadata flagged it.
  • [ ] Chain verify: a "Verify integrity" action over an optional range → /api/verify-chain → render ok as a good status banner, or bad_chain_pos with a critical banner naming the first broken position. (Field is bad_chain_pos — confirmed against the shipped API, not first_bad.)
  • [ ] Access log: /api/access-log?limit table (at, actor [hashed], role, event_id, action) — the "who looked at what" trail, itself the Access Audit Trail deliverable. Note actor is a token-hash, not a name.
  • [ ] 403 handling: if a non-auditor token reaches these (deep link), show a clear "requires auditor role" state, not a crash. Tests: body-view renders pretty+raw; verify-chain ok vs bad banner; 403 state. Commit feat(auditor): recorded body viewer, chain verification, access-log trail.

Task 7: Packaging + live deploy on ai-ahu (exit gate)

Deliverable: The dashboard running on ai-ahu against live data, reachable in a browser, screenshotted.

  • [ ] Finalize Dockerfile (multi-stage: pnpm build → nginx:alpine with deploy/nginx.conf), deploy/compose.yaml (external ahu-platform_default net; host port 8320 — verify free via ss on ai-ahu first). README ≤80 lines: what it is, roles, how the token/proxy model works, quickstart, the OUT-of-scope note (fleet health = P3). Upload README.
  • [ ] rsync repo to efran@192.168.83.20:~/ahu-observatory-dashboard/ (exclude .git/.superpowers/node_modules); docker compose up -d --build; confirm the container is on the platform network and resolves observatory.
  • [ ] Live verification with a real token (operator + auditor from the host's observatory.yaml): load http://192.168.83.20:8320/, log in, confirm executive tiles show the real ingested calls (the 6 ahu-chatbot + others), the call explorer lists them, a body view works AND creates an access-log row (check the access-log tab reflects your own view), verify-chain returns ok. Take screenshots (headless or via the run skill) of the executive view + call explorer and SendUserFile them.
  • [ ] Disk check before build (ai-ahu ~87%): the node build happens locally, only the slim nginx image ships — confirm image < 100MB. Commit feat(deploy): nginx serve+proxy image, compose, README; git tag dash-p1; leave running (note stop command). Write + upload the drill report.

Self-review notes (plan-time)

  • Scope honesty: every panel maps to an endpoint that EXISTS today (verified against the built API surface). Fleet/GPU/queue and true percentiles are explicitly deferred with visible placeholders, not faked — the #1 way a dashboard lies.
  • dataviz compliance is a per-task reviewer gate (palette validated by script, one-axis, fixed-slot categorical, table-view relief, dark mode validated separately) — not left to taste.
  • Security: dashboard holds no token; same-origin nginx proxy avoids CORS and keeps the token in the Authorization header only; body views remain server-logged (the UI can't bypass that). Manual role-pick at login is a documented stopgap for the missing /whoami.
  • The one cross-repo dependency (does /api/summary expose upstream_class for the external-calls tile?) is flagged in Task 4 with a fallback via group_by=upstream — the implementer verifies against server.go and picks the working path.