each turn_stats row already records the model; roll it up per bucket so the /stats page can show which model ran when. model choice greatly affects token cost, so the new stacked-bar chart sits right under the cost chart for eyeball correlation across the window. Snapshot gains a sorted `models` series list; each Bucket carries a `model_counts` map.
338 lines
11 KiB
JavaScript
338 lines
11 KiB
JavaScript
// Per-agent stats page. Fetches /api/state for the title + dashboard link
|
|
// once on load, then /api/stats?window=... for the chart data — re-fetches
|
|
// when the operator clicks a window tab.
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
const palette = {
|
|
bg: cssVar('--bg'),
|
|
bgElev: cssVar('--bg-elev'),
|
|
fg: cssVar('--fg'),
|
|
muted: cssVar('--muted'),
|
|
purple: cssVar('--purple'),
|
|
cyan: cssVar('--cyan'),
|
|
pink: cssVar('--pink'),
|
|
amber: cssVar('--amber'),
|
|
green: cssVar('--green'),
|
|
red: cssVar('--red'),
|
|
border: cssVar('--border'),
|
|
};
|
|
// Distinct hues for categorical charts (top tools / wake mix / result mix).
|
|
const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber,
|
|
palette.green, palette.red, '#94e2d5', '#f9e2af',
|
|
'#74c7ec', '#b4befe'];
|
|
|
|
// Apply Catppuccin defaults globally so each Chart inherits without per-call
|
|
// overrides. Chart.js v4 reads these on chart construction.
|
|
Chart.defaults.color = palette.fg;
|
|
Chart.defaults.borderColor = palette.border;
|
|
Chart.defaults.font.family = '"JetBrains Mono", "Fira Code", monospace';
|
|
Chart.defaults.font.size = 11;
|
|
Chart.defaults.plugins.legend.labels.color = palette.fg;
|
|
|
|
const charts = {};
|
|
let currentWindow = '24h';
|
|
|
|
function fmtMs(ms) {
|
|
if (!Number.isFinite(ms) || ms <= 0) return '0';
|
|
if (ms < 1000) return ms.toFixed(0) + 'ms';
|
|
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's';
|
|
}
|
|
|
|
function fmtInt(n) {
|
|
if (!Number.isFinite(n)) return '0';
|
|
return new Intl.NumberFormat().format(Math.round(n));
|
|
}
|
|
|
|
function bucketLabel(ts, bucketSecs) {
|
|
const d = new Date(ts * 1000);
|
|
if (bucketSecs >= 86400) {
|
|
return d.toISOString().slice(5, 10); // MM-DD
|
|
}
|
|
return d.toISOString().slice(11, 16); // HH:MM
|
|
}
|
|
|
|
function destroy(name) {
|
|
if (charts[name]) {
|
|
charts[name].destroy();
|
|
delete charts[name];
|
|
}
|
|
}
|
|
|
|
function paintEmpty(canvasId, msg) {
|
|
destroy(canvasId);
|
|
const cv = document.getElementById(canvasId);
|
|
if (!cv) return;
|
|
const ctx = cv.getContext('2d');
|
|
ctx.clearRect(0, 0, cv.width, cv.height);
|
|
ctx.fillStyle = palette.muted;
|
|
ctx.font = '12px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(msg, cv.width / 2, cv.height / 2);
|
|
}
|
|
|
|
function renderSummary(s) {
|
|
const root = document.getElementById('summary');
|
|
root.replaceChildren();
|
|
const chips = [
|
|
['turns', fmtInt(s.turn_count)],
|
|
['avg duration', fmtMs(s.duration_summary.avg_ms)],
|
|
['p50 duration', fmtMs(s.duration_summary.p50_ms)],
|
|
['p95 duration', fmtMs(s.duration_summary.p95_ms)],
|
|
['window', s.window],
|
|
];
|
|
for (const [label, value] of chips) {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'chip';
|
|
const l = document.createElement('span');
|
|
l.className = 'label';
|
|
l.textContent = label;
|
|
const v = document.createElement('span');
|
|
v.className = 'value';
|
|
v.textContent = value;
|
|
chip.append(l, v);
|
|
root.append(chip);
|
|
}
|
|
}
|
|
|
|
function renderTurnsChart(s) {
|
|
const id = 'chart-turns';
|
|
destroy(id);
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
const data = s.buckets.map((b) => b.turn_count);
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
label: 'turns',
|
|
data,
|
|
backgroundColor: palette.purple,
|
|
borderColor: palette.purple,
|
|
borderWidth: 1,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { grid: { color: palette.border } },
|
|
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderDurationChart(s) {
|
|
const id = 'chart-duration';
|
|
destroy(id);
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
const ds = (label, color, key) => ({
|
|
label, data: s.buckets.map((b) => b[key]),
|
|
borderColor: color, backgroundColor: color + '33',
|
|
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
|
});
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
ds('p50', palette.cyan, 'p50_duration_ms'),
|
|
ds('p95', palette.pink, 'p95_duration_ms'),
|
|
ds('avg', palette.amber, 'avg_duration_ms'),
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
scales: {
|
|
x: { grid: { color: palette.border } },
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: { color: palette.border },
|
|
ticks: { callback: (v) => fmtMs(v) },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderCtxChart(s) {
|
|
const id = 'chart-ctx';
|
|
destroy(id);
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'avg ctx',
|
|
data: s.buckets.map((b) => b.avg_ctx_tokens),
|
|
borderColor: palette.cyan,
|
|
backgroundColor: palette.cyan + '33',
|
|
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
|
},
|
|
{
|
|
label: 'max ctx',
|
|
data: s.buckets.map((b) => b.max_ctx_tokens),
|
|
borderColor: palette.amber,
|
|
backgroundColor: palette.amber + '33',
|
|
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
scales: {
|
|
x: { grid: { color: palette.border } },
|
|
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderCostChart(s) {
|
|
const id = 'chart-cost';
|
|
destroy(id);
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
// Stacked bars: cache_read (cheap) / cache_creation / input / output.
|
|
// Highlights "what's actually getting billed at full rate" vs cache hits.
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{ label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens),
|
|
backgroundColor: palette.muted },
|
|
{ label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens),
|
|
backgroundColor: palette.cyan },
|
|
{ label: 'input', data: s.buckets.map((b) => b.input_tokens),
|
|
backgroundColor: palette.amber },
|
|
{ label: 'output', data: s.buckets.map((b) => b.output_tokens),
|
|
backgroundColor: palette.pink },
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
scales: {
|
|
x: { stacked: true, grid: { color: palette.border } },
|
|
y: { stacked: true, beginAtZero: true,
|
|
grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderModelChart(s) {
|
|
const id = 'chart-model';
|
|
destroy(id);
|
|
const models = s.models || [];
|
|
if (!models.length) { paintEmpty(id, 'no turns in window'); return; }
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
// One stacked series per model. Model choice drives token cost,
|
|
// so this lines up against the cost chart above it.
|
|
const datasets = models.map((m, i) => ({
|
|
label: m,
|
|
data: s.buckets.map((b) => (b.model_counts && b.model_counts[m]) || 0),
|
|
backgroundColor: wheel[i % wheel.length],
|
|
}));
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'bar',
|
|
data: { labels, datasets },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
|
|
scales: {
|
|
x: { stacked: true, grid: { color: palette.border } },
|
|
y: { stacked: true, beginAtZero: true,
|
|
grid: { color: palette.border }, ticks: { precision: 0 } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderKeyCount(canvasId, items, emptyMsg) {
|
|
destroy(canvasId);
|
|
if (!items || items.length === 0) {
|
|
paintEmpty(canvasId, emptyMsg);
|
|
return;
|
|
}
|
|
const labels = items.map((kc) => kc.key);
|
|
const data = items.map((kc) => kc.count);
|
|
const colors = items.map((_, i) => wheel[i % wheel.length]);
|
|
charts[canvasId] = new Chart(document.getElementById(canvasId), {
|
|
type: 'doughnut',
|
|
data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } },
|
|
},
|
|
});
|
|
}
|
|
|
|
function render(s) {
|
|
renderSummary(s);
|
|
if (s.turn_count === 0) {
|
|
paintEmpty('chart-turns', 'no turns in window');
|
|
paintEmpty('chart-duration', 'no turns in window');
|
|
paintEmpty('chart-ctx', 'no turns in window');
|
|
paintEmpty('chart-cost', 'no turns in window');
|
|
paintEmpty('chart-model', 'no turns in window');
|
|
paintEmpty('chart-tools', 'no tool calls');
|
|
paintEmpty('chart-wake', 'no wakes');
|
|
paintEmpty('chart-result', 'no results');
|
|
return;
|
|
}
|
|
renderTurnsChart(s);
|
|
renderDurationChart(s);
|
|
renderCtxChart(s);
|
|
renderCostChart(s);
|
|
renderModelChart(s);
|
|
renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls');
|
|
renderKeyCount('chart-wake', s.wake_mix, 'no wakes');
|
|
renderKeyCount('chart-result', s.result_mix, 'no results');
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow));
|
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
|
const snap = await resp.json();
|
|
render(snap);
|
|
} catch (e) {
|
|
document.getElementById('summary').textContent = 'stats fetch failed: ' + e;
|
|
}
|
|
}
|
|
|
|
async function loadIdentity() {
|
|
try {
|
|
const resp = await fetch('/api/state');
|
|
if (!resp.ok) return;
|
|
const s = await resp.json();
|
|
document.title = 'stats · ' + s.label;
|
|
document.getElementById('title').textContent = '◆ ' + s.label + ' ◆';
|
|
const dl = document.getElementById('dashboard-link');
|
|
dl.href = 'http://' + window.location.hostname + ':' + s.dashboard_port + '/';
|
|
} catch (_) { /* non-fatal */ }
|
|
}
|
|
|
|
function bindTabs() {
|
|
const tabs = document.getElementById('window-tabs');
|
|
tabs.addEventListener('click', (ev) => {
|
|
const btn = ev.target.closest('button[data-w]');
|
|
if (!btn) return;
|
|
currentWindow = btn.dataset.w;
|
|
for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn);
|
|
loadStats();
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
bindTabs();
|
|
loadIdentity();
|
|
loadStats();
|
|
});
|
|
})();
|