feat(dashboard): add repo filter, sort order, and max results controls to Token Cost page
Three reactive dropdowns below the Token Cost heading: - Filter by repo: client-side filter via 3-level chain resolution - Sort by: Tokens Total (default), Tokens In, Out, Event Count, Most Recent - Show: 10/20/50/100/500 rows per table (default 20) Applies uniformly to By Repo, By Workplan, and Top Tasks tables. "Most Recent" derives last_event_at per group from the fetched events. Truncated tables show a "Showing M of N" count below. Completes CUST-WP-0030 T07–T09. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,31 +41,41 @@ const tokenState = (async function*() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```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) {
|
function buildSummary(events) {
|
||||||
const byWs = {}, byModel = {}, byTask = {};
|
const byWs = {}, byModel = {}, byTask = {};
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
const tot = (e.tokens_in || 0) + (e.tokens_out || 0);
|
const tot = (e.tokens_in || 0) + (e.tokens_out || 0);
|
||||||
if (e.workstream_id) {
|
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] = 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].tokens_out += e.tokens_out || 0;
|
||||||
byWs[e.workstream_id].event_count++;
|
byWs[e.workstream_id].event_count++;
|
||||||
}
|
}
|
||||||
const model = e.model || "unknown";
|
const model = e.model || "unknown";
|
||||||
byModel[model] = (byModel[model] || 0) + tot;
|
byModel[model] = (byModel[model] || 0) + tot;
|
||||||
if (e.task_id) {
|
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] = 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_in += e.tokens_in || 0;
|
||||||
byTask[e.task_id].tokens_out += e.tokens_out || 0;
|
byTask[e.task_id].tokens_out += e.tokens_out || 0;
|
||||||
|
byTask[e.task_id].event_count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sortDesc = obj => Object.entries(obj)
|
const toRows = obj => Object.values(obj)
|
||||||
.map(([k,v]) => typeof v === "number" ? {id: k, tokens_total: v} : {...v, tokens_total: (v.tokens_in||0)+(v.tokens_out||0)})
|
.map(v => ({...v, tokens_total: (v.tokens_in || 0) + (v.tokens_out || 0)}))
|
||||||
.sort((a,b) => b.tokens_total - a.tokens_total);
|
.sort((a, b) => b.tokens_total - a.tokens_total);
|
||||||
return {
|
return {
|
||||||
by_workstream: sortDesc(byWs),
|
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),
|
by_model: Object.entries(byModel)
|
||||||
top_tasks: sortDesc(byTask).slice(0,10),
|
.map(([model, tokens_total]) => ({model, tokens_total}))
|
||||||
|
.sort((a, b) => b.tokens_total - a.tokens_total),
|
||||||
|
top_tasks: toRows(byTask),
|
||||||
total_events: events.length,
|
total_events: events.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -78,13 +88,23 @@ function nameCell(name, fullName) {
|
|||||||
el.textContent = s.length > 80 ? s.slice(0, 80) + "…" : s;
|
el.textContent = s.length > 80 ? s.slice(0, 80) + "…" : s;
|
||||||
return el;
|
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
|
```js
|
||||||
const byRepo = tokenState.byRepo ?? [];
|
const byRepo = tokenState.byRepo ?? [];
|
||||||
const summary = buildSummary(tokenState.events ?? []);
|
const events = tokenState.events ?? [];
|
||||||
const wsMap = tokenState.wsMap ?? {};
|
const wsMap = tokenState.wsMap ?? {};
|
||||||
const taskMap = tokenState.taskMap ?? {};
|
const taskMap = tokenState.taskMap ?? {};
|
||||||
const _ok = tokenState.ok ?? false;
|
const _ok = tokenState.ok ?? false;
|
||||||
const _ts = tokenState.ts;
|
const _ts = tokenState.ts;
|
||||||
```
|
```
|
||||||
@@ -92,87 +112,151 @@ const _ts = tokenState.ts;
|
|||||||
# Token Cost
|
# Token Cost
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const _liveEl = html`<div style="font-size:0.8rem;color:${_ok?'var(--theme-foreground-focus)':'red'}">
|
display(html`<div style="font-size:0.8rem;color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">
|
||||||
● ${_ok ? `Live · ${_ts?.toLocaleTimeString()} · ${summary.total_events} events` : "API offline"}
|
● ${_ok ? `Live · ${_ts?.toLocaleTimeString()} · ${events.length} events` : "API offline"}
|
||||||
</div>`;
|
</div>`);
|
||||||
display(_liveEl);
|
```
|
||||||
|
|
||||||
|
```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`<div style="display:flex;gap:1.5rem;align-items:flex-end;flex-wrap:wrap;margin:0.5rem 0 1.5rem">${repoSel}${sortSel}${maxSel}</div>`);
|
||||||
|
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
|
## By Repo
|
||||||
|
|
||||||
```js
|
```js
|
||||||
if (byRepo.length === 0) {
|
{
|
||||||
display(html`<p style="color:var(--theme-foreground-muted)">No token events with repo association yet.</p>`);
|
const sorted = sortRows(filteredByRepo, sortOrder);
|
||||||
} else {
|
const total = sorted.length;
|
||||||
display(Plot.plot({
|
const rows = sorted.slice(0, maxResults);
|
||||||
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}
|
|
||||||
),
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
display(Inputs.table(byRepo.map((r, i) => ({...r, _ref: i})), {
|
if (rows.length === 0) {
|
||||||
columns: ["_ref", "repo_slug", "tokens_in", "tokens_out", "tokens_total", "event_count"],
|
display(html`<p style="color:var(--theme-foreground-muted)">No token events with repo association yet.</p>`);
|
||||||
header: {
|
} else {
|
||||||
_ref: "REF",
|
display(Plot.plot({
|
||||||
repo_slug: "Repo",
|
title: "Token consumption by repo",
|
||||||
tokens_in: "Tokens In",
|
marginLeft: 160,
|
||||||
tokens_out: "Tokens Out",
|
width: Math.min(900, width),
|
||||||
tokens_total: "Total",
|
x: {label: "Tokens", tickFormat: "~s"},
|
||||||
event_count: "Events",
|
y: {label: null},
|
||||||
},
|
color: {legend: true, domain: ["tokens_in", "tokens_out"], range: ["#4e79a7","#f28e2b"]},
|
||||||
format: {
|
marks: [
|
||||||
_ref: (_, i) => refCell(i + 1, "repos", byRepo[i].repo_slug),
|
Plot.barX(
|
||||||
repo_slug: d => nameCell(d, d),
|
rows.flatMap(r => [
|
||||||
tokens_in: d => d.toLocaleString(),
|
{repo: r.repo_slug, type: "tokens_in", value: r.tokens_in},
|
||||||
tokens_out: d => d.toLocaleString(),
|
{repo: r.repo_slug, type: "tokens_out", value: r.tokens_out},
|
||||||
tokens_total: d => d.toLocaleString(),
|
]),
|
||||||
},
|
{x: "value", y: "repo", fill: "type", tip: true}
|
||||||
width: {_ref: 50, repo_slug: 160, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
|
),
|
||||||
}));
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
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`<p style="font-size:0.8rem;color:var(--theme-foreground-muted);margin-top:0.25rem">Showing ${maxResults} of ${total} repos</p>`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## By Workplan
|
## By Workplan
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const wsRows = summary.by_workstream.slice(0, 20);
|
{
|
||||||
if (wsRows.length === 0) {
|
const sorted = sortRows(wsRowsFull, sortOrder);
|
||||||
display(html`<p style="color:var(--theme-foreground-muted)">No workstream data yet.</p>`);
|
const total = sorted.length;
|
||||||
} else {
|
const rows = sorted.slice(0, maxResults);
|
||||||
display(Inputs.table(wsRows.map((r, i) => ({...r, _ref: i})), {
|
|
||||||
columns: ["_ref", "scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"],
|
if (rows.length === 0) {
|
||||||
header: {
|
display(html`<p style="color:var(--theme-foreground-muted)">No workstream data yet.</p>`);
|
||||||
_ref: "REF",
|
} else {
|
||||||
scope_id: "Workstream",
|
display(Inputs.table(rows.map((r, i) => ({...r, _ref: i})), {
|
||||||
tokens_in: "Tokens In",
|
columns: ["_ref", "scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"],
|
||||||
tokens_out: "Tokens Out",
|
header: {
|
||||||
tokens_total: "Total",
|
_ref: "REF",
|
||||||
event_count: "Events",
|
scope_id: "Workstream",
|
||||||
},
|
tokens_in: "Tokens In",
|
||||||
format: {
|
tokens_out: "Tokens Out",
|
||||||
_ref: (_, i) => refCell(i + 1, "workstreams", wsRows[i].scope_id),
|
tokens_total: "Total",
|
||||||
scope_id: d => {
|
event_count: "Events",
|
||||||
const ws = wsMap[d];
|
|
||||||
return nameCell(ws?.title ?? ws?.slug, d);
|
|
||||||
},
|
},
|
||||||
tokens_in: d => d.toLocaleString(),
|
format: {
|
||||||
tokens_out: d => d.toLocaleString(),
|
_ref: (_, i) => refCell(i + 1, "workstreams", rows[i].scope_id),
|
||||||
tokens_total: d => d.toLocaleString(),
|
scope_id: d => {
|
||||||
},
|
const ws = wsMap[d];
|
||||||
width: {_ref: 50, scope_id: 200, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
|
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`<p style="font-size:0.8rem;color:var(--theme-foreground-muted);margin-top:0.25rem">Showing ${maxResults} of ${total} workstreams</p>`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -194,26 +278,35 @@ if (summary.by_model.length === 0) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Top 10 Tasks by Tokens
|
## Top Tasks by Tokens
|
||||||
|
|
||||||
```js
|
```js
|
||||||
if (summary.top_tasks.length === 0) {
|
{
|
||||||
display(html`<p style="color:var(--theme-foreground-muted)">No task-level data yet.</p>`);
|
const sorted = sortRows(taskRowsFull, sortOrder);
|
||||||
} else {
|
const total = sorted.length;
|
||||||
display(Inputs.table(summary.top_tasks.map((r, i) => ({...r, _ref: i})), {
|
const rows = sorted.slice(0, maxResults);
|
||||||
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"},
|
if (rows.length === 0) {
|
||||||
format: {
|
display(html`<p style="color:var(--theme-foreground-muted)">No task-level data yet.</p>`);
|
||||||
_ref: (_, i) => refCell(i + 1, "tasks", summary.top_tasks[i].task_id),
|
} else {
|
||||||
task_id: d => {
|
display(Inputs.table(rows.map((r, i) => ({...r, _ref: i})), {
|
||||||
const task = taskMap[d];
|
columns: ["_ref", "task_id", "tokens_in", "tokens_out", "tokens_total"],
|
||||||
return nameCell(task?.title, d);
|
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(),
|
width: {_ref: 50, task_id: 240},
|
||||||
tokens_out: d => d.toLocaleString(),
|
}));
|
||||||
tokens_total: d => d.toLocaleString(),
|
|
||||||
},
|
if (total > maxResults)
|
||||||
width: {_ref: 50, task_id: 240},
|
display(html`<p style="font-size:0.8rem;color:var(--theme-foreground-muted);margin-top:0.25rem">Showing ${maxResults} of ${total} tasks</p>`);
|
||||||
}));
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Dashboard Entity List UX"
|
title: "Dashboard Entity List UX"
|
||||||
domain: custodian
|
domain: custodian
|
||||||
repo: the-custodian
|
repo: the-custodian
|
||||||
status: active
|
status: done
|
||||||
owner: custodian
|
owner: custodian
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-03-29"
|
created: "2026-03-29"
|
||||||
|
|||||||
Reference in New Issue
Block a user