feat(dashboard): LLM stats answer who/what spends tokens — member scope chips and spend breakdowns
Nightly Build / build (push) Successful in 4m27s

The stats section was designed single-user: four global charts, no
attribution. Request metadata rows now carry the session's source and
the frame's agent_id/depth (additive ensure_column), denormalized at log
time by the LoggingModel from the owner's pool — off the turn's hot
path, degrading to NULLs, never to a lost row.

The dashboard gains member scope chips filtering every chart, and a
breakdown row splitting the range's billed tokens by member, kind
(chat / sub-agents / cron / system agents / channels), agent, model and
provider — a sub-agent's spend is attributed to the sub-agent itself.
Rows predating the columns group under 'older data'.
This commit is contained in:
Daniele
2026-09-10 14:03:22 +01:00
parent 7e3fa3caad
commit 0958264c6f
14 changed files with 550 additions and 99 deletions
+159 -29
View File
@@ -3,6 +3,38 @@ import { LightElement } from '../lib/base.js';
import { t } from '../lib/i18n.js';
import { InboxMixin } from '../lib/inbox-mixin.js';
// Shared palette for the breakdown bars (cycled when there are more rows).
const PALETTE = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
// The `kind` values that have a proper label (the server sends the session's
// source, or 'sub-agent' for child frames); anything else renders as-is, so a
// new source needs no frontend change to appear.
const KIND_LABELS = {
'web': 'dashboard.stats.kind.web',
'mobile': 'dashboard.stats.kind.mobile',
'telegram': 'dashboard.stats.kind.telegram',
'cron': 'dashboard.stats.kind.cron',
'sub-agent': 'dashboard.stats.kind.sub_agent',
'event-triage': 'dashboard.stats.kind.event_triage',
'memory-lint': 'dashboard.stats.kind.memory_lint',
'conversation-review': 'dashboard.stats.kind.conversation_review',
'unknown': 'dashboard.stats.kind.unknown',
};
function fmtTok(n) {
if (n == null) return '0';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(n);
}
function kindLabel(key) {
const i18nKey = KIND_LABELS[key];
if (!i18nKey) return key;
const label = t(i18nKey);
return label === i18nKey ? key : label;
}
export class DashboardPage extends InboxMixin(LightElement) {
static get properties() {
@@ -13,6 +45,7 @@ export class DashboardPage extends InboxMixin(LightElement) {
_plugins: { state: true },
_stats: { state: true },
_statsRange: { state: true },
_statsUser: { state: true },
};
}
@@ -24,6 +57,9 @@ export class DashboardPage extends InboxMixin(LightElement) {
this._pollTimer = null;
this._stats = null; // null = loading
this._statsRange = 'week';
this._statsUser = ''; // '' = whole instance
this._members = []; // cached out of the stats response: the chips
// must survive a reload that filters them away
this._chartInstances = {};
this._statsTimer = null;
}
@@ -100,11 +136,16 @@ export class DashboardPage extends InboxMixin(LightElement) {
async _loadStats() {
try {
const res = await fetch(`/api/stats/llm?range=${this._statsRange}`);
const params = new URLSearchParams({ range: this._statsRange });
if (this._statsUser) params.set('user', this._statsUser);
const res = await fetch(`/api/stats/llm?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this._stats = await res.json();
if (Array.isArray(this._stats.members) && this._stats.members.length) {
this._members = this._stats.members;
}
} catch {
this._stats = { daily: [], models: [] };
this._stats = { daily: [], by_user: [], by_kind: [], by_agent: [], by_model: [], by_provider: [] };
}
}
@@ -115,6 +156,13 @@ export class DashboardPage extends InboxMixin(LightElement) {
await this._loadStats();
}
async _setUser(user) {
if (user === this._statsUser) return;
this._statsUser = user;
this._stats = null;
await this._loadStats();
}
get _honchoActive() {
return this._plugins?.some(p => p.id === 'honcho' && p.enabled && p.running) ?? false;
}
@@ -161,6 +209,39 @@ export class DashboardPage extends InboxMixin(LightElement) {
.replace(/-\d{8}$/, '');
}
_memberLabel(key) {
if (!key) return t('dashboard.stats.user_unknown');
const m = this._members.find(m => m.id === key);
return m ? m.label : key;
}
// The breakdown cards to show for the current data, in display order. Both
// the render and the chart init consume this, so they can never disagree
// about which canvases exist. The per-member card is pointless — and empty
// by construction — when the scope is already a single member.
get _breakdownSpecs() {
const s = this._stats;
if (!s) return [];
return [
!this._statsUser && { id: 'chart-by-user', title: t('dashboard.stats.by_user'), rows: s.by_user ?? [] },
{ id: 'chart-by-kind', title: t('dashboard.stats.by_kind'), rows: s.by_kind ?? [] },
{ id: 'chart-by-agent', title: t('dashboard.stats.by_agent'), rows: s.by_agent ?? [] },
{ id: 'chart-by-model', title: t('dashboard.stats.by_model'), rows: s.by_model ?? [] },
{ id: 'chart-by-provider', title: t('dashboard.stats.by_provider'), rows: s.by_provider ?? [] },
]
.filter(Boolean)
.filter(c => c.rows.length > 0)
.map(c => ({
...c,
labelFn: c.id === 'chart-by-user' ? k => this._memberLabel(k)
: c.id === 'chart-by-kind' ? k => kindLabel(k)
: c.id === 'chart-by-model' ? k => this._shortModelName(k)
: k => k,
// The axis may carry a shortened label; the tooltip shows the key itself.
titleFn: c.id === 'chart-by-model' ? k => k : null,
}));
}
get _periodLabel() {
return { hour: t('dashboard.stats.per_min'), day: t('dashboard.stats.per_hour'), week: t('dashboard.stats.per_day'), month: t('dashboard.stats.per_day') }[this._statsRange] ?? t('dashboard.stats.per_day');
}
@@ -212,7 +293,6 @@ export class DashboardPage extends InboxMixin(LightElement) {
const cache = filled.map(d => d.cache_read_tokens);
// null for empty slots so the latency line doesn't touch zero where there were no requests
const lat = filled.map(d => d.requests > 0 ? Math.round(d.avg_duration_ms) : null);
const models = this._stats.models;
const axisDefaults = () => ({
ticks: { color: textColor, font: { size: 11 } },
@@ -322,38 +402,76 @@ export class DashboardPage extends InboxMixin(LightElement) {
options: baseOpts(),
});
// Models — always horizontal bar
const c4 = get('chart-models');
if (c4) this._chartInstances.models = new Chart(c4, {
type: 'bar',
data: {
labels: models.map(m => this._shortModelName(m.model_name)),
datasets: [{
data: models.map(m => m.requests),
backgroundColor: ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4'],
borderRadius: 4,
borderSkipped: false,
}],
},
options: {
...baseOpts(),
indexAxis: 'y',
scales: {
x: { ...axisDefaults(), beginAtZero: true },
y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } },
// Breakdowns — "how the spend splits": horizontal bars on billed tokens,
// requests and the input/output/cache split in the tooltip.
for (const spec of this._breakdownSpecs) {
const c = get(spec.id);
if (!c) continue;
this._chartInstances[spec.id] = new Chart(c, {
type: 'bar',
data: {
labels: spec.rows.map(r => spec.labelFn(r.key)),
datasets: [{
data: spec.rows.map(r => r.total_tokens),
backgroundColor: spec.rows.map((_, i) => PALETTE[i % PALETTE.length]),
borderRadius: 4,
borderSkipped: false,
}],
},
},
});
options: {
...baseOpts({
tooltip: {
callbacks: {
title: items => {
const r = spec.rows[items[0]?.dataIndex];
return r ? (spec.titleFn ?? spec.labelFn)(r.key) : '';
},
label: item => {
const r = spec.rows[item.dataIndex];
if (!r) return '';
return [
`${fmtTok(r.total_tokens)} tokens · ${r.requests} ${t('dashboard.stats.tip.requests')}`,
`${t('dashboard.stats.chart.input')}: ${fmtTok(r.input_tokens)} · ${t('dashboard.stats.chart.output')}: ${fmtTok(r.output_tokens)} · ${t('dashboard.stats.chart.cached')}: ${fmtTok(r.cache_read_tokens)}`,
];
},
},
},
}),
indexAxis: 'y',
scales: {
x: { ...axisDefaults(), beginAtZero: true,
ticks: { color: textColor, font: { size: 10 }, callback: v => fmtTok(v) } },
y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } },
},
},
});
}
}
// ── Render ────────────────────────────────────────────────────────────────
_renderScopeChips() {
if (this._members.length < 2) return nothing;
return html`
<div class="home-stats-scope">
<button class="home-stats-range-btn ${this._statsUser === '' ? 'active' : ''}"
@click=${() => this._setUser('')}>${t('dashboard.stats.scope.all')}</button>
${this._members.map(m => html`
<button class="home-stats-range-btn ${this._statsUser === m.id ? 'active' : ''}"
@click=${() => this._setUser(m.id)}>${m.label}</button>
`)}
</div>
`;
}
_renderStats() {
if (this._stats === null) {
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> ${t('dashboard.stats.loading')}</div>`;
}
const empty = this._stats.daily.length === 0 && this._stats.models.length === 0;
const empty = this._stats.daily.length === 0
&& !(this._stats.by_user ?? []).length
&& !(this._stats.by_model ?? []).length;
if (empty) {
return html`
<div class="home-stats-empty">
@@ -363,6 +481,8 @@ export class DashboardPage extends InboxMixin(LightElement) {
`;
}
const breakdown = this._breakdownSpecs;
return html`
<div class="home-stats-grid">
<div class="home-stat-card">
@@ -377,11 +497,20 @@ export class DashboardPage extends InboxMixin(LightElement) {
<div class="home-stat-card-title">${t('dashboard.stats.latency')}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div>
</div>
<div class="home-stat-card">
<div class="home-stat-card-title">${t('dashboard.stats.models')}</div>
<div class="home-stat-canvas-wrap"><canvas id="chart-models"></canvas></div>
</div>
</div>
${breakdown.length ? html`
<div class="home-stats-sub">${t('dashboard.stats.sub.breakdown')}</div>
<div class="home-stats-grid">
${breakdown.map(c => html`
<div class="home-stat-card">
<div class="home-stat-card-title">${c.title}</div>
<div class="home-stat-canvas-wrap" style="height:${Math.max(140, c.rows.length * 30)}px">
<canvas id=${c.id}></canvas>
</div>
</div>
`)}
</div>
` : nothing}
`;
}
@@ -437,6 +566,7 @@ export class DashboardPage extends InboxMixin(LightElement) {
`)}
</div>
</div>
${this._renderScopeChips()}
${this._renderStats()}
<!-- ── Pending inbox ── -->