Files
William Valentin c44e7fe72e refactor(web-ui): extract shared component primitives
Introduce components.js with barTrack, barRow, barRankList, metricPill,
metricStrip, and chartHeader helpers. Migrate dashboard.js and usage.js
to use these primitives, replacing 13 families of duplicated CSS
(stat-list, fw-bar, token-bar, metric-pill, chart-insight, chart-header,
usage-chart-total, etc.) with a unified .am-* namespace. Net: -256 lines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 12:21:48 -07:00

255 lines
9.7 KiB
JavaScript

import { app, isRouteCurrent } from '../router.js';
import { api } from '../api.js';
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
import { barTrack, barRankList, metricStrip, chartHeader } from '../components.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 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>
${barTrack({ value: total, max: maxTotal, fwClass: cssClass, size: 'sm' })}
</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">
${chartHeader({
title: '7-Day Trend',
controls: `
<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>`,
})}
${metricStrip([
{ label: 'runs', value: String(t.runs), variant: 'total' },
{ label: 'tools', value: String(t.tools), variant: 'total' },
{ label: 'errors', value: String(t.errors), variant: 'total', alert: t.errors > 0 },
{ label: 'tokens', value: formatTokenCount(t.tokens), variant: 'total' },
{ label: 'cost', value: formatCost(t.cost), variant: 'total' },
])}
<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>
${barRankList(models, {
mapItem: m => ({ name: m.name, count: m.count, modifier: 'model' }),
maxOverride: maxModel,
emptyText: 'No model data',
})}
</div>
<div class="usage-panel">
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
${barRankList(tools, {
mapItem: x => ({ name: x.name, count: x.count }),
maxOverride: maxTool,
emptyText: 'No tool data',
})}
</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);
});
});
}