feat(dashboard): LLM stats answer who/what spends tokens — member scope chips and spend breakdowns
Nightly Build / build (push) Successful in 4m27s
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:
@@ -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 ── -->
|
||||
|
||||
+19
-1
@@ -312,6 +312,24 @@ dashboard-page {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Scope chips (member filter) — same pill look as the range buttons. */
|
||||
.home-stats-scope {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: -0.25rem 0 1rem;
|
||||
}
|
||||
|
||||
/* Subsection label above the breakdown grid. */
|
||||
.home-stats-sub {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.home-stats-loading,
|
||||
.home-stats-empty {
|
||||
display: flex;
|
||||
@@ -325,7 +343,7 @@ dashboard-page {
|
||||
|
||||
.home-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
+20
-1
@@ -419,7 +419,6 @@ export default {
|
||||
'dashboard.stats.requests': 'Requests {per}',
|
||||
'dashboard.stats.tokens': 'Tokens {per}',
|
||||
'dashboard.stats.latency': 'Avg latency (ms)',
|
||||
'dashboard.stats.models': 'Models',
|
||||
'dashboard.stats.per_min': '/ min',
|
||||
'dashboard.stats.per_hour': '/ hour',
|
||||
'dashboard.stats.per_day': '/ day',
|
||||
@@ -435,6 +434,26 @@ export default {
|
||||
'dashboard.stats.chart.non_cached': 'Non-cached',
|
||||
'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%',
|
||||
|
||||
'dashboard.stats.scope.all': 'Everyone',
|
||||
'dashboard.stats.sub.breakdown': 'How the spend splits in the selected range',
|
||||
'dashboard.stats.by_user': 'By member',
|
||||
'dashboard.stats.by_kind': 'By kind',
|
||||
'dashboard.stats.by_agent': 'By agent',
|
||||
'dashboard.stats.by_model': 'By model',
|
||||
'dashboard.stats.by_provider': 'By provider',
|
||||
'dashboard.stats.user_unknown': 'Unknown',
|
||||
'dashboard.stats.tip.requests': 'requests',
|
||||
|
||||
'dashboard.stats.kind.web': 'Chat',
|
||||
'dashboard.stats.kind.mobile': 'Mobile',
|
||||
'dashboard.stats.kind.telegram': 'Telegram',
|
||||
'dashboard.stats.kind.cron': 'Scheduled tasks',
|
||||
'dashboard.stats.kind.sub_agent': 'Sub-agents',
|
||||
'dashboard.stats.kind.event_triage': 'Event triage',
|
||||
'dashboard.stats.kind.memory_lint': 'Memory lint',
|
||||
'dashboard.stats.kind.conversation_review': 'Conversation review',
|
||||
'dashboard.stats.kind.unknown': 'Older data',
|
||||
|
||||
'dashboard.hero.subtitle': 'Your AI command centre — research, code, plan, and orchestrate. All in one place.',
|
||||
|
||||
'dashboard.banner.no_models.title': 'No LLM models configured.',
|
||||
|
||||
+20
-1
@@ -416,7 +416,6 @@ export default {
|
||||
'dashboard.stats.requests': 'Requêtes {per}',
|
||||
'dashboard.stats.tokens': 'Tokens {per}',
|
||||
'dashboard.stats.latency': 'Latence moyenne (ms)',
|
||||
'dashboard.stats.models': 'Modèles',
|
||||
'dashboard.stats.per_min': '/ min',
|
||||
'dashboard.stats.per_hour': '/ heure',
|
||||
'dashboard.stats.per_day': '/ jour',
|
||||
@@ -432,6 +431,26 @@ export default {
|
||||
'dashboard.stats.chart.non_cached': 'Non en cache',
|
||||
'dashboard.stats.chart.cache_hit': 'Cache hit : {pct}%',
|
||||
|
||||
'dashboard.stats.scope.all': 'Tous',
|
||||
'dashboard.stats.sub.breakdown': 'Répartition des dépenses sur la période sélectionnée',
|
||||
'dashboard.stats.by_user': 'Par membre',
|
||||
'dashboard.stats.by_kind': 'Par type',
|
||||
'dashboard.stats.by_agent': 'Par agent',
|
||||
'dashboard.stats.by_model': 'Par modèle',
|
||||
'dashboard.stats.by_provider': 'Par fournisseur',
|
||||
'dashboard.stats.user_unknown': 'Inconnu',
|
||||
'dashboard.stats.tip.requests': 'requêtes',
|
||||
|
||||
'dashboard.stats.kind.web': 'Chat',
|
||||
'dashboard.stats.kind.mobile': 'Mobile',
|
||||
'dashboard.stats.kind.telegram': 'Telegram',
|
||||
'dashboard.stats.kind.cron': 'Tâches planifiées',
|
||||
'dashboard.stats.kind.sub_agent': 'Sous-agents',
|
||||
'dashboard.stats.kind.event_triage': 'Triage des événements',
|
||||
'dashboard.stats.kind.memory_lint': 'Lint mémoire',
|
||||
'dashboard.stats.kind.conversation_review': 'Revue des conversations',
|
||||
'dashboard.stats.kind.unknown': 'Données anciennes',
|
||||
|
||||
'dashboard.hero.subtitle': 'Votre centre de commande IA — recherche, code, planification et orchestration. Tout en un seul endroit.',
|
||||
|
||||
'dashboard.banner.no_models.title': 'Aucun modèle LLM configuré.',
|
||||
|
||||
+20
-1
@@ -416,7 +416,6 @@ export default {
|
||||
'dashboard.stats.requests': 'Richieste {per}',
|
||||
'dashboard.stats.tokens': 'Token {per}',
|
||||
'dashboard.stats.latency': 'Latenza media (ms)',
|
||||
'dashboard.stats.models': 'Modelli',
|
||||
'dashboard.stats.per_min': '/ min',
|
||||
'dashboard.stats.per_hour': '/ h',
|
||||
'dashboard.stats.per_day': '/ giorno',
|
||||
@@ -432,6 +431,26 @@ export default {
|
||||
'dashboard.stats.chart.non_cached': 'Non in cache',
|
||||
'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%',
|
||||
|
||||
'dashboard.stats.scope.all': 'Tutti',
|
||||
'dashboard.stats.sub.breakdown': 'Come si distribuisce la spesa nel periodo selezionato',
|
||||
'dashboard.stats.by_user': 'Per membro',
|
||||
'dashboard.stats.by_kind': 'Per tipo',
|
||||
'dashboard.stats.by_agent': 'Per agente',
|
||||
'dashboard.stats.by_model': 'Per modello',
|
||||
'dashboard.stats.by_provider': 'Per provider',
|
||||
'dashboard.stats.user_unknown': 'Sconosciuto',
|
||||
'dashboard.stats.tip.requests': 'richieste',
|
||||
|
||||
'dashboard.stats.kind.web': 'Chat',
|
||||
'dashboard.stats.kind.mobile': 'Mobile',
|
||||
'dashboard.stats.kind.telegram': 'Telegram',
|
||||
'dashboard.stats.kind.cron': 'Attività pianificate',
|
||||
'dashboard.stats.kind.sub_agent': 'Sub-agent',
|
||||
'dashboard.stats.kind.event_triage': 'Triage eventi',
|
||||
'dashboard.stats.kind.memory_lint': 'Lint memoria',
|
||||
'dashboard.stats.kind.conversation_review': 'Revisione conversazioni',
|
||||
'dashboard.stats.kind.unknown': 'Dati precedenti',
|
||||
|
||||
'dashboard.hero.subtitle': 'Il tuo centro di comando AI — ricerca, codice, pianificazione e orchestrazione. Tutto in un unico posto.',
|
||||
|
||||
'dashboard.banner.no_models.title': 'Nessun modello LLM configurato.',
|
||||
|
||||
Reference in New Issue
Block a user