diff --git a/api/routers/repos.py b/api/routers/repos.py
index 5365c94..b6f5db0 100644
--- a/api/routers/repos.py
+++ b/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/api/routers/token_events.py b/api/routers/token_events.py
index c3f8ee2..f529957 100644
--- a/api/routers/token_events.py
+++ b/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/dashboard/src/components/field-help.js b/dashboard/src/components/field-help.js
new file mode 100644
index 0000000..1c6ad8c
--- /dev/null
+++ b/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/dashboard/src/components/ref-cell.js b/dashboard/src/components/ref-cell.js
new file mode 100644
index 0000000..025182e
--- /dev/null
+++ b/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/dashboard/src/data/repos/[slug].json.py b/dashboard/src/data/repos/[slug].json.py
new file mode 100644
index 0000000..fe81175
--- /dev/null
+++ b/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/dashboard/src/data/token-events/[id].json.py b/dashboard/src/data/token-events/[id].json.py
new file mode 100644
index 0000000..7f448ec
--- /dev/null
+++ b/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/dashboard/src/data/workstreams/[id].json.py b/dashboard/src/data/workstreams/[id].json.py
new file mode 100644
index 0000000..92774cb
--- /dev/null
+++ b/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/dashboard/src/reference.md b/dashboard/src/reference.md
index c3060de..070c99a 100644
--- a/dashboard/src/reference.md
+++ b/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/dashboard/src/repos/[slug].md b/dashboard/src/repos/[slug].md
new file mode 100644
index 0000000..3a090d6
--- /dev/null
+++ b/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``);
+}
+```
diff --git a/dashboard/src/tasks/[id].md b/dashboard/src/tasks/[id].md
new file mode 100644
index 0000000..8e7308d
--- /dev/null
+++ b/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``);
+}
+```
diff --git a/dashboard/src/token-cost.md b/dashboard/src/token-cost.md
index 1ad4b9b..df001a7 100644
--- a/dashboard/src/token-cost.md
+++ b/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/dashboard/src/token-events/[id].md b/dashboard/src/token-events/[id].md
new file mode 100644
index 0000000..770d3fd
--- /dev/null
+++ b/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``);
+}
+```
diff --git a/dashboard/src/workstreams/[id].md b/dashboard/src/workstreams/[id].md
new file mode 100644
index 0000000..dbcd46c
--- /dev/null
+++ b/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``);
+}
+```
diff --git a/tests/test_token_events.py b/tests/test_token_events.py
index 1df9da4..324e95e 100644
--- a/tests/test_token_events.py
+++ b/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