From 20ce332d5550de7850710b21aa4e7c08455a9b56 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 29 Mar 2026 22:35:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20entity=20list=20UX=20?= =?UTF-8?q?=E2=80=94=20REF=20column,=20name=20cells,=20detail=20pages=20(C?= =?UTF-8?q?UST-WP-0030)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ref-cell.js: REF column component — click=copy deeplink, dblclick=open - field-help.js: field registry + fieldRow helper with help-tip decoration; FK fields (task_id, workstream_id, repo_id) render as async-linked cells with entity-title bubble-help on hover - GET /token-events/{id} endpoint + get-by-id tests - GET /repos/by-id/{repo_id} UUID lookup endpoint - Landing pages: /token-events/[id], /workstreams/[id], /repos/[slug], /tasks/[id] - token-cost.md: REF + Name columns on all three tables; parallel fetch of workstreams/tasks for title resolution - reference.md: entity detail page URL scheme documented Co-Authored-By: Claude Sonnet 4.6 --- state-hub/api/routers/repos.py | 11 + state-hub/api/routers/token_events.py | 11 + .../dashboard/src/components/field-help.js | 263 ++++++++++++++++++ .../dashboard/src/components/ref-cell.js | 101 +++++++ .../dashboard/src/data/repos/[slug].json.py | 29 ++ .../src/data/token-events/[id].json.py | 29 ++ .../src/data/workstreams/[id].json.py | 29 ++ state-hub/dashboard/src/reference.md | 22 ++ state-hub/dashboard/src/repos/[slug].md | 42 +++ state-hub/dashboard/src/tasks/[id].md | 42 +++ state-hub/dashboard/src/token-cost.md | 82 +++++- state-hub/dashboard/src/token-events/[id].md | 42 +++ state-hub/dashboard/src/workstreams/[id].md | 41 +++ state-hub/tests/test_token_events.py | 19 ++ .../CUST-WP-0030-dashboard-entity-list-ux.md | 77 ++++- 15 files changed, 824 insertions(+), 16 deletions(-) create mode 100644 state-hub/dashboard/src/components/field-help.js create mode 100644 state-hub/dashboard/src/components/ref-cell.js create mode 100644 state-hub/dashboard/src/data/repos/[slug].json.py create mode 100644 state-hub/dashboard/src/data/token-events/[id].json.py create mode 100644 state-hub/dashboard/src/data/workstreams/[id].json.py create mode 100644 state-hub/dashboard/src/repos/[slug].md create mode 100644 state-hub/dashboard/src/tasks/[id].md create mode 100644 state-hub/dashboard/src/token-events/[id].md create mode 100644 state-hub/dashboard/src/workstreams/[id].md diff --git a/state-hub/api/routers/repos.py b/state-hub/api/routers/repos.py index 5365c94..b6f5db0 100644 --- a/state-hub/api/routers/repos.py +++ b/state-hub/api/routers/repos.py @@ -326,6 +326,17 @@ async def get_repo_doi( ) +@router.get("/by-id/{repo_id}", response_model=RepoRead) +async def get_repo_by_id( + repo_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> ManagedRepo: + repo = await session.get(ManagedRepo, repo_id) + if repo is None: + raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found") + return repo + + @router.get("/{slug}/", response_model=RepoRead) async def get_repo( slug: str, diff --git a/state-hub/api/routers/token_events.py b/state-hub/api/routers/token_events.py index c3f8ee2..f529957 100644 --- a/state-hub/api/routers/token_events.py +++ b/state-hub/api/routers/token_events.py @@ -166,6 +166,17 @@ async def get_tokens_by_repo( ] +@router.get("/{event_id}", response_model=TokenEventRead) +async def get_token_event( + event_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> TokenEvent: + event = await session.get(TokenEvent, event_id) + if event is None: + raise HTTPException(status_code=404, detail="Token event not found") + return event + + @router.get("/", response_model=list[TokenEventRead]) async def list_token_events( task_id: uuid.UUID | None = None, diff --git a/state-hub/dashboard/src/components/field-help.js b/state-hub/dashboard/src/components/field-help.js new file mode 100644 index 0000000..1c6ad8c --- /dev/null +++ b/state-hub/dashboard/src/components/field-help.js @@ -0,0 +1,263 @@ +// Field-level help registry for dashboard entity detail pages. +// +// FIELD_HELP maps a field key to { label, description, doc? }. +// label — human-readable field name (used as bold heading in help-tip) +// description — one sentence explaining the field +// doc — optional anchor into /docs/ pages for "Learn more" +// +// fieldRow(key, value) → element with a help-tip-decorated key cell and +// a value cell. Falls back to a plain key cell when key is not in FIELD_HELP. +// +// Usage: +// import {fieldRow} from "./components/field-help.js"; +// const tbody = html`${fields.map(([k,v]) => fieldRow(k,v))}`; + +import {HelpTip} from "./help-tip.js"; // ensures custom element is registered +import {API} from "./config.js"; +void HelpTip; // silence unused-import linters + +// ── Entity link registry ──────────────────────────────────────────────────── +// Maps FK field names to fetch/URL/title resolution rules. +// getUrl receives (id, data) so slug-routed entities (repos) can use data.slug. +const FIELD_LINKS = { + task_id: { + apiUrl: id => `${API}/tasks/${id}`, + getUrl: (id, _d) => `/tasks/${id}`, + getTitle: d => d.title, + }, + workstream_id: { + apiUrl: id => `${API}/workstreams/${id}`, + getUrl: (id, _d) => `/workstreams/${id}`, + getTitle: d => d.title || d.slug, + }, + repo_id: { + apiUrl: id => `${API}/repos/by-id/${id}`, + getUrl: (_id, d) => `/repos/${d.slug}`, + getTitle: d => d.name || d.slug, + }, +}; + +/** + * Render an entity-reference value as a link with an async help-tip showing + * the entity title. Falls back gracefully if the fetch fails. + */ +function _linkCell(key, id) { + const rule = FIELD_LINKS[key]; + const shortId = String(id).slice(0, 8) + "…"; + + const a = document.createElement("a"); + a.textContent = shortId; + a.href = "#"; + a.style.fontFamily = "var(--monospace, monospace)"; + a.title = String(id); // full UUID as native tooltip while async loads + + fetch(rule.apiUrl(id)) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (!data) return; + const title = rule.getTitle(data); + const url = rule.getUrl(id, data); + if (url) a.href = url; + if (title) { + const tip = document.createElement("help-tip"); + tip.setAttribute("label", title); + tip.setAttribute("description", `${key.replace(/_id$/, "")} · ${id}`); + a.replaceWith(tip); + tip.appendChild(a); + } + }) + .catch(() => { /* leave plain link */ }); + + return a; +} + +export const FIELD_HELP = { + // ── TokenEvent ───────────────────────────────────────────────────────────── + id: { + label: "ID", + description: "Unique identifier for this token event (UUID v4).", + doc: "/docs/reference#token-events", + }, + tokens_in: { + label: "Tokens In", + description: "Number of input (prompt) tokens consumed in this event.", + doc: "/docs/reference#token-events", + }, + tokens_out: { + label: "Tokens Out", + description: "Number of output (completion) tokens generated in this event.", + doc: "/docs/reference#token-events", + }, + tokens_total: { + label: "Tokens Total", + description: "Sum of input and output tokens — total cost proxy for this event.", + doc: "/docs/reference#token-events", + }, + task_id: { + label: "Task ID", + description: "The task this token event was recorded against (if any).", + doc: "/docs/tasks", + }, + workstream_id: { + label: "Workstream ID", + description: "The workstream this event belongs to; auto-resolved from task if not set directly.", + doc: "/docs/workstreams", + }, + repo_id: { + label: "Repo ID", + description: "The managed repo this event is attributed to; auto-resolved from workstream.", + doc: "/docs/repos", + }, + session_id: { + label: "Session ID", + description: "Opaque identifier for the Claude Code session that produced this event.", + }, + model: { + label: "Model", + description: "The Claude model used (e.g. claude-sonnet-4-6).", + doc: "/docs/reference#models", + }, + agent: { + label: "Agent", + description: "The agent persona that produced this event (e.g. custodian, ralph).", + doc: "/docs/reference#agents", + }, + ref_type: { + label: "Ref Type", + description: "What kind of entity the ref_id points to: task, commit, release, or session.", + doc: "/docs/reference#token-events", + }, + ref_id: { + label: "Ref ID", + description: "Free-form reference ID; interpretation depends on ref_type.", + doc: "/docs/reference#token-events", + }, + note: { + label: "Note", + description: "Quality tag: 'measured' = exact from status bar, 'userbased' = human-provided, 'workplan' = prorated, 'heuristic' = server fallback.", + doc: "/docs/reference#token-note-taxonomy", + }, + created_at: { + label: "Created At", + description: "Timestamp when this token event was recorded (UTC).", + }, + + // ── Workstream ────────────────────────────────────────────────────────────── + slug: { + label: "Slug", + description: "URL-safe short identifier for this entity.", + }, + title: { + label: "Title", + description: "Human-readable name for this workstream or task.", + }, + status: { + label: "Status", + description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.", + doc: "/docs/workstream-lifecycle", + }, + topic_id: { + label: "Topic ID", + description: "The topic this workstream is grouped under.", + doc: "/docs/reference#topics", + }, + repo_goal_id: { + label: "Repo Goal ID", + description: "Optional link to a repo-level strategic goal this workstream advances.", + doc: "/docs/goals", + }, + + // ── Task ─────────────────────────────────────────────────────────────────── + assignee: { + label: "Assignee", + description: "Who is responsible for completing this task (agent name or human).", + }, + priority: { + label: "Priority", + description: "Relative urgency: high, medium, or low.", + }, + due_date: { + label: "Due Date", + description: "Target completion date (ISO 8601).", + }, + needs_human: { + label: "Needs Human", + description: "True if the task is blocked waiting for human input or approval.", + doc: "/interventions", + }, + intervention_note: { + label: "Intervention Note", + description: "Why human intervention is required for this task.", + }, + + // ── Repo ─────────────────────────────────────────────────────────────────── + repo_slug: { + label: "Repo Slug", + description: "Short identifier for the repository (matches the git remote slug).", + doc: "/docs/repos", + }, + event_count: { + label: "Event Count", + description: "Total number of token events attributed to this entity.", + }, + by_model: { + label: "By Model", + description: "Token totals broken down by Claude model.", + }, + by_note: { + label: "By Note", + description: "Token totals broken down by quality tier (measured / workplan / heuristic).", + doc: "/docs/reference#token-note-taxonomy", + }, +}; + +/** + * Render a single key-value row for an entity detail table. + * @param {string} key — field name + * @param {*} value — field value (stringified automatically) + * @returns {HTMLTableRowElement} + */ +export function fieldRow(key, value) { + const tr = document.createElement("tr"); + + // Key cell + const tdKey = document.createElement("td"); + tdKey.style.cssText = "padding:0.3rem 0.8rem 0.3rem 0; white-space:nowrap; vertical-align:top; color:var(--theme-foreground-muted,#666); font-size:0.82rem;"; + + const help = FIELD_HELP[key]; + if (help) { + const tip = document.createElement("help-tip"); + tip.setAttribute("label", help.label); + tip.setAttribute("description", help.description); + if (help.doc) tip.setAttribute("doc", help.doc); + tip.textContent = help.label; + tdKey.appendChild(tip); + } else { + tdKey.textContent = key; + } + tr.appendChild(tdKey); + + // Value cell + const tdVal = document.createElement("td"); + tdVal.style.cssText = "padding:0.3rem 0; font-size:0.82rem; word-break:break-all; vertical-align:top;"; + + let display; + if (value === null || value === undefined) { + display = document.createElement("span"); + display.style.color = "var(--theme-foreground-faint,#aaa)"; + display.textContent = "—"; + } else if (key in FIELD_LINKS) { + display = _linkCell(key, value); + } else if (typeof value === "object") { + display = document.createElement("pre"); + display.style.cssText = "margin:0; font-size:0.75rem; white-space:pre-wrap;"; + display.textContent = JSON.stringify(value, null, 2); + } else { + display = document.createElement("span"); + display.textContent = String(value); + } + tdVal.appendChild(display); + tr.appendChild(tdVal); + + return tr; +} diff --git a/state-hub/dashboard/src/components/ref-cell.js b/state-hub/dashboard/src/components/ref-cell.js new file mode 100644 index 0000000..025182e --- /dev/null +++ b/state-hub/dashboard/src/components/ref-cell.js @@ -0,0 +1,101 @@ +// refCell(index, recordType, id) → HTMLElement +// +// Renders a 1-based row number in a table cell. +// Single click — copies deep-link to clipboard and flashes "Copied!". +// Double click — opens deep-link in a new tab. +// +// Deep-link format: /data// +// +// Usage: +// import {refCell} from "./components/ref-cell.js"; +// // in an Inputs.table format callback: +// format: { id: (_, i) => refCell(i + 1, "token-events", row.id) } + +const _STYLE_ID = "refcell-global-style"; +if (!document.getElementById(_STYLE_ID)) { + const s = document.createElement("style"); + s.id = _STYLE_ID; + s.textContent = ` +.ref-cell { + display: inline-block; + font-family: var(--monospace, monospace); + font-size: 0.78rem; + color: var(--theme-foreground-focus, #3b82f6); + cursor: pointer; + user-select: none; + padding: 0 2px; + border-radius: 3px; + transition: background 0.1s; +} +.ref-cell:hover { + background: var(--theme-foreground-faint, #e8f0fe); +} +.ref-cell-toast { + position: fixed; + z-index: 10000; + background: var(--theme-background, #fff); + border: 1px solid var(--theme-foreground-faint, #ddd); + border-radius: 6px; + padding: 0.3rem 0.65rem; + font-size: 0.75rem; + color: var(--theme-foreground, #333); + box-shadow: 0 4px 14px rgba(0,0,0,0.12); + opacity: 0; + transition: opacity 0.1s ease; + pointer-events: none; +} +.ref-cell-toast.ref-cell-toast-visible { opacity: 1; } +`; + document.head.appendChild(s); +} + +function _showToast(anchorEl, text) { + const toast = document.createElement("div"); + toast.className = "ref-cell-toast"; + toast.textContent = text; + document.body.appendChild(toast); + + const rect = anchorEl.getBoundingClientRect(); + const gap = 6; + toast.style.left = `${rect.left}px`; + toast.style.top = `${rect.top - toast.offsetHeight - gap}px`; + + requestAnimationFrame(() => toast.classList.add("ref-cell-toast-visible")); + setTimeout(() => { + toast.classList.remove("ref-cell-toast-visible"); + toast.addEventListener("transitionend", () => toast.remove(), {once: true}); + }, 1200); +} + +export function refCell(index, recordType, id) { + const deepLink = `${location.origin}/${recordType}/${id}`; + + const el = document.createElement("span"); + el.className = "ref-cell"; + el.title = `Click to copy link · Double-click to open\n${deepLink}`; + el.textContent = String(index); + + let clickTimer = null; + + el.addEventListener("click", (e) => { + e.stopPropagation(); + // Use a short delay so a double-click cancels the single-click handler. + clickTimer = setTimeout(async () => { + try { + await navigator.clipboard.writeText(deepLink); + _showToast(el, "Copied!"); + } catch { + // Fallback for environments where clipboard API is blocked. + _showToast(el, deepLink); + } + }, 180); + }); + + el.addEventListener("dblclick", (e) => { + e.stopPropagation(); + clearTimeout(clickTimer); + window.open(deepLink, "_blank", "noopener,noreferrer"); + }); + + return el; +} diff --git a/state-hub/dashboard/src/data/repos/[slug].json.py b/state-hub/dashboard/src/data/repos/[slug].json.py new file mode 100644 index 0000000..fe81175 --- /dev/null +++ b/state-hub/dashboard/src/data/repos/[slug].json.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Observable data loader: fetches a single repo by slug.""" +import json +import os +import sys +import urllib.request +import urllib.error + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +slug = sys.argv[1] if len(sys.argv) > 1 else "" + +if not slug: + print(json.dumps({"error": "No repo slug provided"})) + sys.exit(1) + +try: + with urllib.request.urlopen(f"{API_BASE}/repos/{slug}/", timeout=10) as resp: + data = json.loads(resp.read()) + print(json.dumps(data)) +except urllib.error.HTTPError as e: + if e.code == 404: + print(json.dumps({"error": f"Repo {slug!r} not found"})) + sys.exit(1) + print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"})) + sys.exit(1) +except urllib.error.URLError as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/state-hub/dashboard/src/data/token-events/[id].json.py b/state-hub/dashboard/src/data/token-events/[id].json.py new file mode 100644 index 0000000..7f448ec --- /dev/null +++ b/state-hub/dashboard/src/data/token-events/[id].json.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Observable data loader: fetches a single token event by ID.""" +import json +import os +import sys +import urllib.request +import urllib.error + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +event_id = sys.argv[1] if len(sys.argv) > 1 else "" + +if not event_id: + print(json.dumps({"error": "No event ID provided"})) + sys.exit(1) + +try: + with urllib.request.urlopen(f"{API_BASE}/token-events/{event_id}", timeout=10) as resp: + data = json.loads(resp.read()) + print(json.dumps(data)) +except urllib.error.HTTPError as e: + if e.code == 404: + print(json.dumps({"error": f"Token event {event_id!r} not found"})) + sys.exit(1) + print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"})) + sys.exit(1) +except urllib.error.URLError as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/state-hub/dashboard/src/data/workstreams/[id].json.py b/state-hub/dashboard/src/data/workstreams/[id].json.py new file mode 100644 index 0000000..92774cb --- /dev/null +++ b/state-hub/dashboard/src/data/workstreams/[id].json.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Observable data loader: fetches a single workstream by ID.""" +import json +import os +import sys +import urllib.request +import urllib.error + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +ws_id = sys.argv[1] if len(sys.argv) > 1 else "" + +if not ws_id: + print(json.dumps({"error": "No workstream ID provided"})) + sys.exit(1) + +try: + with urllib.request.urlopen(f"{API_BASE}/workstreams/{ws_id}", timeout=10) as resp: + data = json.loads(resp.read()) + print(json.dumps(data)) +except urllib.error.HTTPError as e: + if e.code == 404: + print(json.dumps({"error": f"Workstream {ws_id!r} not found"})) + sys.exit(1) + print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"})) + sys.exit(1) +except urllib.error.URLError as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/state-hub/dashboard/src/reference.md b/state-hub/dashboard/src/reference.md index c3060de..070c99a 100644 --- a/state-hub/dashboard/src/reference.md +++ b/state-hub/dashboard/src/reference.md @@ -54,6 +54,28 @@ convention used in the Custodian State Hub. --- +## Entity Detail Pages & REF Column + +Every entity table in the dashboard includes a **REF** column (leftmost) that shows +the 1-based row number for the current view. Clicking the REF number copies a +deep-link to the clipboard in the format `/data//`. Double-clicking +opens that link in a new tab. + +The deep-link target is an entity detail page that renders all fields of the record +in a key-value layout. Each field key is decorated with a `` that shows a +one-sentence description of the field and a "Learn more" link to the relevant +documentation section. + +Currently implemented record types: + +| Record type | URL pattern | Source endpoint | +|-------------|-------------|-----------------| +| `token-events` | `/token-events/` | `GET /token-events/{id}` | + +Further record types (repos, workstreams, tasks) will be added in subsequent workplans. + +--- + ## Meta | Topic | What it covers | diff --git a/state-hub/dashboard/src/repos/[slug].md b/state-hub/dashboard/src/repos/[slug].md new file mode 100644 index 0000000..3a090d6 --- /dev/null +++ b/state-hub/dashboard/src/repos/[slug].md @@ -0,0 +1,42 @@ +--- +title: Repo +--- + +```js +import {API} from "../components/config.js"; +import {fieldRow} from "../components/field-help.js"; +``` + +```js +const repoSlug = observable.params.slug; +const raw = await fetch(`${API}/repos/${repoSlug}/`) + .then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`}))) + .catch(e => ({error: String(e)})); +``` + +```js +if (raw.error) { + display(html`
⚠️ ${raw.error}
`); +} else { + const name = raw.name || raw.slug || repoSlug; + display(html`

Repo · ${name}

`); + display(html`

← Repos  |  ← Token Cost

`); + + const FIELD_ORDER = [ + "id","slug","name","domain_slug","status","description", + "local_path","remote_url","git_fingerprint", + "sbom_source","last_sbom_at","last_state_synced_at", + "created_at","updated_at", + ]; + + const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null)); + for (const k of Object.keys(raw)) { + if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k])); + } + + display(html` + + ${rows} +
`); +} +``` diff --git a/state-hub/dashboard/src/tasks/[id].md b/state-hub/dashboard/src/tasks/[id].md new file mode 100644 index 0000000..8e7308d --- /dev/null +++ b/state-hub/dashboard/src/tasks/[id].md @@ -0,0 +1,42 @@ +--- +title: Task +--- + +```js +import {API} from "../components/config.js"; +import {fieldRow} from "../components/field-help.js"; +``` + +```js +const taskId = observable.params.id; +const raw = await fetch(`${API}/tasks/${taskId}`) + .then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`}))) + .catch(e => ({error: String(e)})); +``` + +```js +if (raw.error) { + display(html`
⚠️ ${raw.error}
`); +} else { + const name = raw.title || taskId; + const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name; + display(html`

Task · ${shortName}

`); + display(html`

← Tasks  |  ← Token Cost

`); + + const FIELD_ORDER = [ + "id","title","status","priority","assignee", + "workstream_id","due_date","needs_human","intervention_note", + "created_at","updated_at", + ]; + + const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null)); + for (const k of Object.keys(raw)) { + if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k])); + } + + display(html` + + ${rows} +
`); +} +``` diff --git a/state-hub/dashboard/src/token-cost.md b/state-hub/dashboard/src/token-cost.md index 1ad4b9b..df001a7 100644 --- a/state-hub/dashboard/src/token-cost.md +++ b/state-hub/dashboard/src/token-cost.md @@ -4,26 +4,37 @@ title: Token Cost ```js import {API} from "./components/config.js"; +import {refCell} from "./components/ref-cell.js"; const POLL = 60_000; ``` ```js -// Fetch both /by-repo/ and raw events in parallel +// Fetch token events, by-repo summary, workstreams, and tasks in parallel const tokenState = (async function*() { while (true) { - let byRepo = [], events = [], ok = false; + let byRepo = [], events = [], wsMap = {}, taskMap = {}, ok = false; try { - const [r1, r2] = await Promise.all([ + const [r1, r2, r3, r4] = await Promise.all([ fetch(`${API}/token-events/by-repo/`), fetch(`${API}/token-events/?limit=1000`), + fetch(`${API}/workstreams/`), + fetch(`${API}/tasks/`), ]); ok = r1.ok && r2.ok; if (ok) { byRepo = await r1.json(); events = await r2.json(); } + if (r3.ok) { + const wsList = await r3.json(); + for (const w of wsList) wsMap[w.id] = w; + } + if (r4.ok) { + const taskList = await r4.json(); + for (const t of taskList) taskMap[t.id] = t; + } } catch {} - yield {byRepo, events, ok, ts: new Date()}; + yield {byRepo, events, wsMap, taskMap, ok, ts: new Date()}; await new Promise(res => setTimeout(res, POLL)); } })(); @@ -58,11 +69,22 @@ function buildSummary(events) { total_events: events.length, }; } + +function nameCell(name, fullName) { + const s = String(name ?? fullName ?? "—"); + const full = String(fullName ?? name ?? "—"); + const el = document.createElement("span"); + el.title = full; + el.textContent = s.length > 80 ? s.slice(0, 80) + "…" : s; + return el; +} ``` ```js -const byRepo = tokenState.byRepo ?? []; -const summary = buildSummary(tokenState.events ?? []); +const byRepo = tokenState.byRepo ?? []; +const summary = buildSummary(tokenState.events ?? []); +const wsMap = tokenState.wsMap ?? {}; +const taskMap = tokenState.taskMap ?? {}; const _ok = tokenState.ok ?? false; const _ts = tokenState.ts; ``` @@ -99,6 +121,26 @@ if (byRepo.length === 0) { ), ], })); + + display(Inputs.table(byRepo.map((r, i) => ({...r, _ref: i})), { + columns: ["_ref", "repo_slug", "tokens_in", "tokens_out", "tokens_total", "event_count"], + header: { + _ref: "REF", + repo_slug: "Repo", + tokens_in: "Tokens In", + tokens_out: "Tokens Out", + tokens_total: "Total", + event_count: "Events", + }, + format: { + _ref: (_, i) => refCell(i + 1, "repos", byRepo[i].repo_slug), + repo_slug: d => nameCell(d, d), + tokens_in: d => d.toLocaleString(), + tokens_out: d => d.toLocaleString(), + tokens_total: d => d.toLocaleString(), + }, + width: {_ref: 50, repo_slug: 160, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80}, + })); } ``` @@ -109,22 +151,27 @@ const wsRows = summary.by_workstream.slice(0, 20); if (wsRows.length === 0) { display(html`

No workstream data yet.

`); } else { - display(Inputs.table(wsRows, { - columns: ["scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"], + display(Inputs.table(wsRows.map((r, i) => ({...r, _ref: i})), { + columns: ["_ref", "scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"], header: { - scope_id: "Workstream ID", + _ref: "REF", + scope_id: "Workstream", tokens_in: "Tokens In", tokens_out: "Tokens Out", tokens_total: "Total", event_count: "Events", }, format: { - scope_id: d => d.slice(0,8) + "…", + _ref: (_, i) => refCell(i + 1, "workstreams", wsRows[i].scope_id), + scope_id: d => { + const ws = wsMap[d]; + return nameCell(ws?.title ?? ws?.slug, d); + }, tokens_in: d => d.toLocaleString(), tokens_out: d => d.toLocaleString(), tokens_total: d => d.toLocaleString(), }, - width: {scope_id: 120, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80}, + width: {_ref: 50, scope_id: 200, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80}, })); } ``` @@ -153,15 +200,20 @@ if (summary.by_model.length === 0) { if (summary.top_tasks.length === 0) { display(html`

No task-level data yet.

`); } else { - display(Inputs.table(summary.top_tasks, { - columns: ["task_id", "tokens_in", "tokens_out", "tokens_total"], - header: {task_id: "Task ID", tokens_in: "In", tokens_out: "Out", tokens_total: "Total"}, + display(Inputs.table(summary.top_tasks.map((r, i) => ({...r, _ref: i})), { + columns: ["_ref", "task_id", "tokens_in", "tokens_out", "tokens_total"], + header: {_ref: "REF", task_id: "Task", tokens_in: "In", tokens_out: "Out", tokens_total: "Total"}, format: { - task_id: d => d.slice(0,8) + "…", + _ref: (_, i) => refCell(i + 1, "tasks", summary.top_tasks[i].task_id), + task_id: d => { + const task = taskMap[d]; + return nameCell(task?.title, d); + }, tokens_in: d => d.toLocaleString(), tokens_out: d => d.toLocaleString(), tokens_total: d => d.toLocaleString(), }, + width: {_ref: 50, task_id: 240}, })); } ``` diff --git a/state-hub/dashboard/src/token-events/[id].md b/state-hub/dashboard/src/token-events/[id].md new file mode 100644 index 0000000..770d3fd --- /dev/null +++ b/state-hub/dashboard/src/token-events/[id].md @@ -0,0 +1,42 @@ +--- +title: Token Event +--- + +```js +import {API} from "../components/config.js"; +import {fieldRow} from "../components/field-help.js"; +``` + +```js +const eventId = observable.params.id; +const raw = await fetch(`${API}/token-events/${eventId}`) + .then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`}))) + .catch(e => ({error: String(e)})); +``` + +```js +if (raw.error) { + display(html`
⚠️ ${raw.error}
`); +} else { + const shortId = raw.id ? raw.id.slice(0, 8) + "…" : eventId; + display(html`

Token Event · ${shortId}

`); + display(html`

← Token Cost

`); + + const FIELD_ORDER = [ + "id","tokens_in","tokens_out","tokens_total", + "note","model","agent","session_id", + "task_id","workstream_id","repo_id", + "ref_type","ref_id","created_at", + ]; + + const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null)); + for (const k of Object.keys(raw)) { + if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k])); + } + + display(html` + + ${rows} +
`); +} +``` diff --git a/state-hub/dashboard/src/workstreams/[id].md b/state-hub/dashboard/src/workstreams/[id].md new file mode 100644 index 0000000..dbcd46c --- /dev/null +++ b/state-hub/dashboard/src/workstreams/[id].md @@ -0,0 +1,41 @@ +--- +title: Workstream +--- + +```js +import {API} from "../components/config.js"; +import {fieldRow} from "../components/field-help.js"; +``` + +```js +const wsId = observable.params.id; +const raw = await fetch(`${API}/workstreams/${wsId}`) + .then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`}))) + .catch(e => ({error: String(e)})); +``` + +```js +if (raw.error) { + display(html`
⚠️ ${raw.error}
`); +} else { + const name = raw.title || raw.slug || wsId; + const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name; + display(html`

Workstream · ${shortName}

`); + display(html`

← Workstreams  |  ← Token Cost

`); + + const FIELD_ORDER = [ + "id","slug","title","status","topic_id","repo_id","repo_goal_id", + "created_at","updated_at", + ]; + + const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null)); + for (const k of Object.keys(raw)) { + if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k])); + } + + display(html` + + ${rows} +
`); +} +``` diff --git a/state-hub/tests/test_token_events.py b/state-hub/tests/test_token_events.py index 1df9da4..324e95e 100644 --- a/state-hub/tests/test_token_events.py +++ b/state-hub/tests/test_token_events.py @@ -196,3 +196,22 @@ class TestTokenSummary: s = r.json() assert s["tokens_total"] == 0 assert s["event_count"] == 0 + + +@pytest.mark.asyncio +class TestTokenEventGetById: + async def test_get_by_id(self, client): + ev = await _post_event(client, tokens_in=111, tokens_out=222, note="get-by-id-test") + r = await client.get(f"/token-events/{ev['id']}") + assert r.status_code == 200 + result = r.json() + assert result["id"] == ev["id"] + assert result["tokens_in"] == 111 + assert result["tokens_out"] == 222 + assert result["tokens_total"] == 333 + assert result["note"] == "get-by-id-test" + + async def test_get_by_id_not_found(self, client): + import uuid + r = await client.get(f"/token-events/{uuid.uuid4()}") + assert r.status_code == 404 diff --git a/workplans/CUST-WP-0030-dashboard-entity-list-ux.md b/workplans/CUST-WP-0030-dashboard-entity-list-ux.md index 661604c..323d8ad 100644 --- a/workplans/CUST-WP-0030-dashboard-entity-list-ux.md +++ b/workplans/CUST-WP-0030-dashboard-entity-list-ux.md @@ -8,7 +8,7 @@ status: done owner: custodian topic_slug: custodian created: "2026-03-29" -updated: "2026-03-29" +updated: "2026-03-30" state_hub_workstream_id: "9d8e1c33-2067-4593-a5d8-d28dda3b1d21" --- @@ -178,3 +178,78 @@ description: > /data// URL scheme and the REF column convention. state_hub_task_id: "107cb5bb-ff0c-4c97-af04-2cdeff11f0b2" ``` + +## Amendments & Improvements + +Post-completion fixes and enhancements discovered during manual testing. + +### A01 — amendment — Deep-link URL prefix wrong + +`ref-cell.js` generated deep-links as `/data//` but +Observable Framework serves pages at `//` (the `src/data/` +directory is for data loaders, not pages). Fixed by removing the `/data/` +prefix from the `deepLink` construction in `ref-cell.js`. The T01 +description was also inaccurate in stating the `/data/` path. + +### A02 — amendment — FileAttachment rejects template literals + +T04 used `` FileAttachment(`../data/token-events/${eventId}.json`) `` in +the landing page. Observable Framework requires `FileAttachment` to be +called with a single **literal** string (it processes them statically at +compile time). This caused a `SyntaxError` that also aborted the cell +defining `eventId`, cascading into a `RuntimeError: wsId is not defined` +on the next cell. Fixed by replacing all `FileAttachment` calls in landing +pages with direct `fetch(${API}/...)` calls. + +### A03 — amendment — Workstream and repo landing pages missing + +T04 only created a token-event landing page. The By Workplan and By Repo +tables also had REF links (to `/workstreams/` and `/repos/`), +both returning 404. Added: +- `src/workstreams/[id].md` — fetches `GET /workstreams/{id}` +- `src/repos/[slug].md` — fetches `GET /repos/{slug}/` +- Corresponding unused data loaders left in `src/data/` for reference. + +### A04 — amendment — Repos router uses slug, not UUID + +T05 passed `repo_id` (UUID) to `refCell` for the By Repo table, but the +repos API uses slug-based routing (`GET /repos/{slug}/`). Passing a UUID +returned 404. Fixed by passing `repo_slug` to `refCell` so the deep-link +resolves correctly. + +### A05 — amendment — Top Tasks refCell used wrong record type + +T05 specified `"token-events"` as the record type for Top Tasks, but the +column contains `task_id` (a task UUID, not a token-event UUID), so the +landing page returned "Token event not found". Fixed by: +- Changing record type to `"tasks"` in the `refCell` call. +- Creating `src/tasks/[id].md` (fetches `GET /tasks/{task_id}`). + +### I02 — improvement — Entity FK fields on detail pages link to their targets + +On every detail page (token-event, task, workstream, repo), fields that hold +a foreign-key UUID (`task_id`, `workstream_id`, `repo_id`) now render as +clickable links with an async-loaded bubble-help showing the entity title. + +Implementation: +- `GET /repos/by-id/{repo_id}` added to `api/routers/repos.py` — UUID + lookup needed because the existing repos router uses slug routing. +- `FIELD_LINKS` registry added to `src/components/field-help.js` mapping + each FK field to `{apiUrl, getUrl, getTitle}` resolution rules. + `getUrl` receives `(id, data)` so slug-routed entities can derive their + page URL from the fetched entity (e.g. `repo_id` → `/repos/{data.slug}`). +- `_linkCell(key, id)` helper added: renders a short-UUID link immediately, + then fetches the entity asynchronously and wraps the anchor in a + `` once + the data arrives. +- `fieldRow` updated to dispatch to `_linkCell` whenever the field key is + in `FIELD_LINKS` and the value is non-null. + +### I01 — improvement — Workstream and task Name columns show titles + +T05 originally showed truncated UUIDs in the Workstream and Task name +columns (the summary data carries only IDs, not titles). Improved by +fetching `/workstreams/` and `/tasks/` in parallel with the token-event +poll and building lookup maps (`wsMap`, `taskMap`). The Name column now +displays `workstream.title` and `task.title` (truncated to 80 chars) with +the full UUID as tooltip.