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>
This commit is contained in:
@@ -2,6 +2,130 @@ 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>
|
||||
@@ -18,90 +142,149 @@ export async function renderUsage(routeToken) {
|
||||
|
||||
const tools = toolsData.tools || [];
|
||||
const models = modelsData.models || [];
|
||||
const series = tsData.series || [];
|
||||
|
||||
// Aggregate 7d totals from timeseries
|
||||
const totals7d = series.reduce((acc, b) => {
|
||||
acc.runs += b.runs || 0;
|
||||
acc.tools += b.tools || 0;
|
||||
acc.errors += b.errors || 0;
|
||||
acc.tokens += b.tokens || 0;
|
||||
acc.cost += b.cost || 0;
|
||||
return acc;
|
||||
}, { runs: 0, tools: 0, errors: 0, tokens: 0, cost: 0 });
|
||||
|
||||
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}</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></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Cost Today</div><div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div></div>
|
||||
<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">
|
||||
<div class="section-title">7-Day Totals</div>
|
||||
<div class="usage-7d-tiles">
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Runs</span><strong>${totals7d.runs}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tool Calls</span><strong>${totals7d.tools}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Errors</span><strong>${totals7d.errors}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tokens</span><strong>${formatTokenCount(totals7d.tokens)}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Est. Cost</span><strong>${formatCost(totals7d.cost)}</strong></div>
|
||||
<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">No model data yet</p>' : `
|
||||
<ul class="stat-list" id="usage-models-list">
|
||||
${(() => {
|
||||
const max = models[0]?.count || 1;
|
||||
return models.map(m => {
|
||||
const pct = (m.count / max * 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('');
|
||||
})()}
|
||||
${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">No tool data yet</p>' : `
|
||||
<ul class="stat-list" id="usage-tools-list">
|
||||
${(() => {
|
||||
const max = tools[0]?.count || 1;
|
||||
return tools.map(t => {
|
||||
const pct = (t.count / max * 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 tool" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
})()}
|
||||
${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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user