diff --git a/state-hub/dashboard/src/token-cost.md b/state-hub/dashboard/src/token-cost.md index df001a7..a24d20e 100644 --- a/state-hub/dashboard/src/token-cost.md +++ b/state-hub/dashboard/src/token-cost.md @@ -41,31 +41,41 @@ const tokenState = (async function*() { ``` ```js +// Resolve an event's repo_id via the 3-level chain: direct → workstream → task→workstream +function resolveRepoId(e, wsMap, taskMap) { + if (e.repo_id) return e.repo_id; + const wsId = e.workstream_id ?? taskMap[e.task_id]?.workstream_id; + return wsId ? (wsMap[wsId]?.repo_id ?? null) : null; +} + function buildSummary(events) { const byWs = {}, byModel = {}, byTask = {}; for (const e of events) { const tot = (e.tokens_in || 0) + (e.tokens_out || 0); if (e.workstream_id) { byWs[e.workstream_id] = byWs[e.workstream_id] || {scope_id: e.workstream_id, tokens_in: 0, tokens_out: 0, event_count: 0}; - byWs[e.workstream_id].tokens_in += e.tokens_in || 0; + byWs[e.workstream_id].tokens_in += e.tokens_in || 0; byWs[e.workstream_id].tokens_out += e.tokens_out || 0; byWs[e.workstream_id].event_count++; } const model = e.model || "unknown"; byModel[model] = (byModel[model] || 0) + tot; if (e.task_id) { - byTask[e.task_id] = byTask[e.task_id] || {task_id: e.task_id, tokens_in: 0, tokens_out: 0}; - byTask[e.task_id].tokens_in += e.tokens_in || 0; + byTask[e.task_id] = byTask[e.task_id] || {task_id: e.task_id, tokens_in: 0, tokens_out: 0, event_count: 0}; + byTask[e.task_id].tokens_in += e.tokens_in || 0; byTask[e.task_id].tokens_out += e.tokens_out || 0; + byTask[e.task_id].event_count++; } } - const sortDesc = obj => Object.entries(obj) - .map(([k,v]) => typeof v === "number" ? {id: k, tokens_total: v} : {...v, tokens_total: (v.tokens_in||0)+(v.tokens_out||0)}) - .sort((a,b) => b.tokens_total - a.tokens_total); + const toRows = obj => Object.values(obj) + .map(v => ({...v, tokens_total: (v.tokens_in || 0) + (v.tokens_out || 0)})) + .sort((a, b) => b.tokens_total - a.tokens_total); return { - by_workstream: sortDesc(byWs), - by_model: Object.entries(byModel).map(([model,tokens_total]) => ({model,tokens_total})).sort((a,b)=>b.tokens_total-a.tokens_total), - top_tasks: sortDesc(byTask).slice(0,10), + by_workstream: toRows(byWs), + by_model: Object.entries(byModel) + .map(([model, tokens_total]) => ({model, tokens_total})) + .sort((a, b) => b.tokens_total - a.tokens_total), + top_tasks: toRows(byTask), total_events: events.length, }; } @@ -78,13 +88,23 @@ function nameCell(name, fullName) { el.textContent = s.length > 80 ? s.slice(0, 80) + "…" : s; return el; } + +function sortRows(rows, sortField) { + if (sortField === "Tokens Total") return rows; // already sorted by buildSummary / by-repo API + const s = [...rows]; + if (sortField === "Tokens In") s.sort((a, b) => (b.tokens_in || 0) - (a.tokens_in || 0)); + else if (sortField === "Tokens Out") s.sort((a, b) => (b.tokens_out || 0) - (a.tokens_out || 0)); + else if (sortField === "Event Count") s.sort((a, b) => (b.event_count || 0) - (a.event_count || 0)); + else if (sortField === "Most Recent") s.sort((a, b) => (b._lastAt || 0) - (a._lastAt || 0)); + return s; +} ``` ```js -const byRepo = tokenState.byRepo ?? []; -const summary = buildSummary(tokenState.events ?? []); -const wsMap = tokenState.wsMap ?? {}; -const taskMap = tokenState.taskMap ?? {}; +const byRepo = tokenState.byRepo ?? []; +const events = tokenState.events ?? []; +const wsMap = tokenState.wsMap ?? {}; +const taskMap = tokenState.taskMap ?? {}; const _ok = tokenState.ok ?? false; const _ts = tokenState.ts; ``` @@ -92,87 +112,151 @@ const _ts = tokenState.ts; # Token Cost ```js -const _liveEl = html`
- ● ${_ok ? `Live · ${_ts?.toLocaleTimeString()} · ${summary.total_events} events` : "API offline"} -
`; -display(_liveEl); +display(html`
+ ● ${_ok ? `Live · ${_ts?.toLocaleTimeString()} · ${events.length} events` : "API offline"} +
`); +``` + +```js +const repoSel = Inputs.select( + ["All repos", ...byRepo.map(r => r.repo_slug)], + {label: "Filter by repo"} +); +const sortSel = Inputs.select( + ["Tokens Total", "Tokens In", "Tokens Out", "Event Count", "Most Recent"], + {label: "Sort by"} +); +const maxSel = Inputs.select( + [10, 20, 50, 100, 500], + {value: 20, label: "Show"} +); +display(html`
${repoSel}${sortSel}${maxSel}
`); +const repoFilter = view(repoSel); +const sortOrder = view(sortSel); +const maxResults = view(maxSel); +``` + +```js +// Build filtered and last-event-annotated row sets +const selectedRepoId = repoFilter === "All repos" + ? null + : (byRepo.find(r => r.repo_slug === repoFilter)?.repo_id ?? null); + +const filteredEvents = selectedRepoId + ? events.filter(e => resolveRepoId(e, wsMap, taskMap) === selectedRepoId) + : events; + +const lastAtByRepo = {}, lastAtByWs = {}, lastAtByTask = {}; +for (const e of filteredEvents) { + const t = e.created_at ? new Date(e.created_at).getTime() : 0; + const rid = resolveRepoId(e, wsMap, taskMap); + if (rid) lastAtByRepo[rid] = Math.max(lastAtByRepo[rid] || 0, t); + if (e.workstream_id) lastAtByWs[e.workstream_id] = Math.max(lastAtByWs[e.workstream_id] || 0, t); + if (e.task_id) lastAtByTask[e.task_id] = Math.max(lastAtByTask[e.task_id] || 0, t); +} + +const filteredByRepo = (selectedRepoId + ? byRepo.filter(r => r.repo_id === selectedRepoId) + : byRepo +).map(r => ({...r, _lastAt: lastAtByRepo[r.repo_id] || 0})); + +const summary = buildSummary(filteredEvents); +const wsRowsFull = summary.by_workstream.map(r => ({...r, _lastAt: lastAtByWs[r.scope_id] || 0})); +const taskRowsFull = summary.top_tasks.map(r => ({...r, _lastAt: lastAtByTask[r.task_id] || 0})); ``` ## By Repo ```js -if (byRepo.length === 0) { - display(html`

No token events with repo association yet.

`); -} else { - display(Plot.plot({ - title: "Token consumption by repo", - marginLeft: 160, - width: Math.min(900, width), - x: {label: "Tokens", tickFormat: "~s"}, - y: {label: null}, - color: {legend: true, domain: ["tokens_in", "tokens_out"], range: ["#4e79a7","#f28e2b"]}, - marks: [ - Plot.barX( - byRepo.flatMap(r => [ - {repo: r.repo_slug, type: "tokens_in", value: r.tokens_in}, - {repo: r.repo_slug, type: "tokens_out", value: r.tokens_out}, - ]), - {x: "value", y: "repo", fill: "type", tip: true} - ), - ], - })); +{ + const sorted = sortRows(filteredByRepo, sortOrder); + const total = sorted.length; + const rows = sorted.slice(0, maxResults); - 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}, - })); + if (rows.length === 0) { + display(html`

No token events with repo association yet.

`); + } else { + display(Plot.plot({ + title: "Token consumption by repo", + marginLeft: 160, + width: Math.min(900, width), + x: {label: "Tokens", tickFormat: "~s"}, + y: {label: null}, + color: {legend: true, domain: ["tokens_in", "tokens_out"], range: ["#4e79a7","#f28e2b"]}, + marks: [ + Plot.barX( + rows.flatMap(r => [ + {repo: r.repo_slug, type: "tokens_in", value: r.tokens_in}, + {repo: r.repo_slug, type: "tokens_out", value: r.tokens_out}, + ]), + {x: "value", y: "repo", fill: "type", tip: true} + ), + ], + })); + + display(Inputs.table(rows.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", rows[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}, + })); + + if (total > maxResults) + display(html`

Showing ${maxResults} of ${total} repos

`); + } } ``` ## By Workplan ```js -const wsRows = summary.by_workstream.slice(0, 20); -if (wsRows.length === 0) { - display(html`

No workstream data yet.

`); -} else { - display(Inputs.table(wsRows.map((r, i) => ({...r, _ref: i})), { - columns: ["_ref", "scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"], - header: { - _ref: "REF", - scope_id: "Workstream", - tokens_in: "Tokens In", - tokens_out: "Tokens Out", - tokens_total: "Total", - event_count: "Events", - }, - format: { - _ref: (_, i) => refCell(i + 1, "workstreams", wsRows[i].scope_id), - scope_id: d => { - const ws = wsMap[d]; - return nameCell(ws?.title ?? ws?.slug, d); +{ + const sorted = sortRows(wsRowsFull, sortOrder); + const total = sorted.length; + const rows = sorted.slice(0, maxResults); + + if (rows.length === 0) { + display(html`

No workstream data yet.

`); + } else { + display(Inputs.table(rows.map((r, i) => ({...r, _ref: i})), { + columns: ["_ref", "scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"], + header: { + _ref: "REF", + scope_id: "Workstream", + tokens_in: "Tokens In", + tokens_out: "Tokens Out", + tokens_total: "Total", + event_count: "Events", }, - tokens_in: d => d.toLocaleString(), - tokens_out: d => d.toLocaleString(), - tokens_total: d => d.toLocaleString(), - }, - width: {_ref: 50, scope_id: 200, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80}, - })); + format: { + _ref: (_, i) => refCell(i + 1, "workstreams", rows[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: {_ref: 50, scope_id: 200, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80}, + })); + + if (total > maxResults) + display(html`

Showing ${maxResults} of ${total} workstreams

`); + } } ``` @@ -194,26 +278,35 @@ if (summary.by_model.length === 0) { } ``` -## Top 10 Tasks by Tokens +## Top Tasks by Tokens ```js -if (summary.top_tasks.length === 0) { - display(html`

No task-level data yet.

`); -} else { - 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: { - _ref: (_, i) => refCell(i + 1, "tasks", summary.top_tasks[i].task_id), - task_id: d => { - const task = taskMap[d]; - return nameCell(task?.title, d); +{ + const sorted = sortRows(taskRowsFull, sortOrder); + const total = sorted.length; + const rows = sorted.slice(0, maxResults); + + if (rows.length === 0) { + display(html`

No task-level data yet.

`); + } else { + display(Inputs.table(rows.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: { + _ref: (_, i) => refCell(i + 1, "tasks", rows[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(), }, - tokens_in: d => d.toLocaleString(), - tokens_out: d => d.toLocaleString(), - tokens_total: d => d.toLocaleString(), - }, - width: {_ref: 50, task_id: 240}, - })); + width: {_ref: 50, task_id: 240}, + })); + + if (total > maxResults) + display(html`

Showing ${maxResults} of ${total} tasks

`); + } } ``` diff --git a/workplans/CUST-WP-0030-dashboard-entity-list-ux.md b/workplans/CUST-WP-0030-dashboard-entity-list-ux.md index 86b9197..3e3e32e 100644 --- a/workplans/CUST-WP-0030-dashboard-entity-list-ux.md +++ b/workplans/CUST-WP-0030-dashboard-entity-list-ux.md @@ -4,7 +4,7 @@ type: workplan title: "Dashboard Entity List UX" domain: custodian repo: the-custodian -status: active +status: done owner: custodian topic_slug: custodian created: "2026-03-29"