Files
agentmon/cmd/web-ui/static/modules/pages/usage.js
T
William Valentin 8753c0c9d5 feat(web-ui): better stats and ergonomics
Usage page: add 7-day trend chart (activity/tokens/cost tabs),
framework breakdown panel with per-framework run/tool/error counts
and proportional bars, and 7d aggregate pills above the chart.

Dashboard: add avg cost/run metric pill to the metrics strip.

Run detail: extract and display prompt preview from the first agent
span's payload above the spans table.

Bug fixes: stat-list bars now render correctly (flex-direction:column),
right-panel-tab active background uses correct accent color, missing
framework colors added for hermes/codex/gemini/copilot. Dead code
renderSessionRow removed from sessions.js. Hardcoded font-family
replaced with CSS variable in metric-pill-value and token-stat-value.
Usage page cleanup() wired into router teardown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:49:05 -07:00

291 lines
11 KiB
JavaScript

import { app, isRouteCurrent } from '../router.js';
import { api } from '../api.js';
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
/* global uPlot */
let usageChart = null;
let usageChartMode = 'activity';
let usageResizeObserver = null;
let _usageSeries = [];
export function cleanup() {
if (usageChart) { usageChart.destroy(); usageChart = null; }
if (usageResizeObserver) { usageResizeObserver.disconnect(); usageResizeObserver = null; }
_usageSeries = [];
}
function buildChartData(series, mode) {
if (!series || series.length === 0) return null;
const ts = series.map(b => Math.floor(new Date(b.ts).getTime() / 1000));
if (mode === 'tokens') {
return [ts, series.map(b => b.input_tokens || 0), series.map(b => b.output_tokens || 0)];
}
if (mode === 'cost') {
return [ts, series.map(b => b.cost || 0)];
}
return [ts, series.map(b => b.runs || 0), series.map(b => b.tools || 0), series.map(b => b.errors || 0)];
}
function renderChart(series, mode) {
const container = document.getElementById('usage-chart');
if (!container) return;
if (usageChart) { usageChart.destroy(); usageChart = null; }
container.innerHTML = '';
const data = buildChartData(series, mode);
if (!data) {
container.innerHTML = '<p class="empty-state" style="padding:1.5rem">No data for this period</p>';
return;
}
const width = container.clientWidth || 580;
const height = 160;
const axisStyle = {
stroke: '#4e6070',
grid: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
ticks: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
font: '11px Fira Code',
};
let seriesDef;
if (mode === 'tokens') {
seriesDef = [
{},
{ label: 'Input', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
{ label: 'Output', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
];
} else if (mode === 'cost') {
seriesDef = [
{},
{ label: 'Cost', stroke: '#fbbf24', width: 1.75, fill: 'rgba(251,191,36,0.1)', points: { show: false } },
];
} else {
seriesDef = [
{},
{ label: 'Runs', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
{ label: 'Tools', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
{ label: 'Errors', stroke: '#f87171', width: 1.5, fill: 'rgba(248,113,113,0.08)', points: { show: false } },
];
}
usageChart = new window.uPlot({
width, height,
cursor: { show: true },
scales: { x: { time: true }, y: { auto: true, min: 0 } },
axes: [{ ...axisStyle }, { ...axisStyle, size: 52 }],
series: seriesDef,
}, data, container);
if (usageResizeObserver) usageResizeObserver.disconnect();
usageResizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
if (usageChart) usageChart.setSize({ width: entry.contentRect.width, height });
}
});
usageResizeObserver.observe(container);
}
function renderFrameworkBreakdown(byFw) {
const el = document.getElementById('usage-fw-breakdown');
if (!el) return;
const entries = Object.entries(byFw || {}).sort((a, b) => {
return (b[1].runs + b[1].tools + b[1].errors) - (a[1].runs + a[1].tools + a[1].errors);
});
if (entries.length === 0) {
el.innerHTML = '<p class="empty-state" style="padding:1rem">No framework data</p>';
return;
}
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors), 1);
el.innerHTML = entries.map(([name, stats]) => {
const total = stats.runs + stats.tools + stats.errors;
const pct = (total / maxTotal * 100).toFixed(1);
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const active = (stats.active_sessions || 0) > 0;
return `
<div class="usage-fw-row">
<div class="usage-fw-name">
<span class="fw-dot ${escapeHTML(cssClass)} ${active ? 'active' : 'ended'}"></span>
<span>${escapeHTML(name)}</span>
${active ? `<span class="usage-fw-active-badge">${stats.active_sessions} live</span>` : ''}
</div>
<div class="usage-fw-stats">
<span class="usage-fw-stat"><span class="usage-fw-stat-label">runs</span>${stats.runs || 0}</span>
<span class="usage-fw-stat"><span class="usage-fw-stat-label">tools</span>${stats.tools || 0}</span>
${(stats.errors || 0) > 0 ? `<span class="usage-fw-stat error"><span class="usage-fw-stat-label">err</span>${stats.errors}</span>` : ''}
</div>
<div class="usage-fw-bar-track">
<div class="usage-fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
</div>
</div>`;
}).join('');
}
export async function renderUsage(routeToken) {
app.innerHTML = `
<div class="page-header"><h2>Usage</h2></div>
<div id="usage-content"><div class="usage-loading">Loading…</div></div>
`;
const [summary, toolsData, modelsData, tsData] = await Promise.all([
api('/v1/stats/summary').catch(() => null),
api('/v1/stats/top-tools?limit=20').catch(() => ({ tools: [] })),
api('/v1/stats/top-models?limit=10').catch(() => ({ models: [] })),
api('/v1/stats/timeseries?window=7d').catch(() => ({ series: [] })),
]);
if (routeToken && !isRouteCurrent(routeToken)) return;
const tools = toolsData.tools || [];
const models = modelsData.models || [];
const s = summary || {};
_usageSeries = tsData.series || [];
const t = _usageSeries.reduce((acc, b) => {
acc.runs += b.runs || 0;
acc.tools += b.tools || 0;
acc.errors += b.errors || 0;
acc.tokens += b.tokens || 0;
acc.itok += b.input_tokens || 0;
acc.otok += b.output_tokens || 0;
acc.cost += b.cost || 0;
return acc;
}, { runs: 0, tools: 0, errors: 0, tokens: 0, itok: 0, otok: 0, cost: 0 });
const maxModel = models[0]?.count || 1;
const maxTool = tools[0]?.count || 1;
const content = document.getElementById('usage-content');
if (!content) return;
content.innerHTML = `
<div class="usage-summary-tiles">
<div class="meta-tile">
<div class="meta-tile-label">Active Sessions</div>
<div class="meta-tile-value">${s.active_sessions || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Runs Today</div>
<div class="meta-tile-value">${s.runs_today || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Tool Calls Today</div>
<div class="meta-tile-value">${s.tool_calls_today || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Errors Today</div>
<div class="meta-tile-value${(s.errors_today || 0) > 0 ? ' has-errors' : ''}">${s.errors_today || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Tokens Today</div>
<div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div>
${(s.tokens_today || 0) > 0 ? `<div class="meta-tile-sub">${formatTokenCount(s.tokens_today * 0.7 || 0)} in · ${formatTokenCount(s.tokens_today * 0.3 || 0)} out</div>` : ''}
</div>
<div class="meta-tile">
<div class="meta-tile-label">Cost Today</div>
<div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div>
${(s.runs_today || 0) > 0 ? `<div class="meta-tile-sub">${formatCost((s.cost_today || 0) / s.runs_today)}/run</div>` : ''}
</div>
</div>
<div class="usage-section-row">
<div class="usage-panel usage-chart-panel">
<div class="usage-chart-header">
<span class="section-title" style="margin:0">7-Day Trend</span>
<div class="usage-chart-tabs" id="usage-chart-tabs">
<button class="usage-chart-tab active" data-mode="activity">Activity</button>
<button class="usage-chart-tab" data-mode="tokens">Tokens</button>
<button class="usage-chart-tab" data-mode="cost">Cost</button>
</div>
</div>
<div class="usage-chart-totals">
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">runs</span>
<strong>${t.runs}</strong>
</span>
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">tools</span>
<strong>${t.tools}</strong>
</span>
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">errors</span>
<strong class="${t.errors > 0 ? 'usage-total-errors' : ''}">${t.errors}</strong>
</span>
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">tokens</span>
<strong>${formatTokenCount(t.tokens)}</strong>
</span>
<span class="usage-chart-total-pill">
<span class="usage-chart-total-label">cost</span>
<strong>${formatCost(t.cost)}</strong>
</span>
</div>
<div id="usage-chart"></div>
</div>
<div class="usage-panel usage-fw-panel">
<div class="section-title" style="margin-bottom:0.75rem">
Frameworks
<span class="count">today</span>
</div>
<div id="usage-fw-breakdown"></div>
</div>
</div>
<div class="usage-section-row">
<div class="usage-panel">
<div class="section-title">Top Models <span class="count">${models.length}</span></div>
${models.length === 0 ? '<p class="empty-state" style="padding:1rem">No model data</p>' : `
<ul class="stat-list">
${models.map(m => {
const pct = (m.count / maxModel * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(m.name)}</span>
<span class="stat-list-count">${m.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
</div>
</li>`;
}).join('')}
</ul>`}
</div>
<div class="usage-panel">
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
${tools.length === 0 ? '<p class="empty-state" style="padding:1rem">No tool data</p>' : `
<ul class="stat-list">
${tools.map(t => {
const pct = (t.count / maxTool * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(t.name)}</span>
<span class="stat-list-count">${t.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill" style="width:${pct}%"></div>
</div>
</li>`;
}).join('')}
</ul>`}
</div>
</div>
`;
renderChart(_usageSeries, usageChartMode);
renderFrameworkBreakdown(s.by_framework);
document.querySelectorAll('.usage-chart-tab').forEach(btn => {
btn.addEventListener('click', () => {
if (usageChartMode === btn.dataset.mode) return;
usageChartMode = btn.dataset.mode;
document.querySelectorAll('.usage-chart-tab').forEach(b => b.classList.toggle('active', b === btn));
renderChart(_usageSeries, usageChartMode);
});
});
}