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>
This commit is contained in:
William Valentin
2026-05-22 12:21:48 -07:00
parent 8753c0c9d5
commit c44e7fe72e
4 changed files with 384 additions and 538 deletions
+102
View File
@@ -0,0 +1,102 @@
// ── components.js — shared UI primitives ─────────────────
//
// One-stop helpers for the repeating bar / pill / chart-header
// patterns across pages. Renderers return HTML strings — callers
// inject via innerHTML and wire events on the resulting DOM.
import { escapeHTML } from './utils.js';
// ── Bar primitives ───────────────────────────────────────
// barTrack — just the percentage bar (no label/count head).
// value: numeric value to display
// max: ceiling for percentage; 0/falsy means 0%
// fwClass: optional framework slug (openclaw, claude-code, hermes, …)
// — applied as fw-<slug> on the fill for color
// size: 'xs' | 'sm' | 'md' | 'lg' (default 'md')
// modifier: extra class on the fill (e.g. 'model', 'input', 'output')
export function barTrack({ value = 0, max = 0, fwClass = '', size = 'md', modifier = '' } = {}) {
const pct = max > 0 ? Math.min(100, (value / max) * 100).toFixed(1) : '0';
const fillClasses = ['am-bar-fill'];
if (fwClass) fillClasses.push('fw-' + fwClass);
if (modifier) fillClasses.push(modifier);
return `<div class="am-bar-track am-bar-track--${escapeHTML(size)}"><div class="${fillClasses.join(' ')}" style="width:${pct}%"></div></div>`;
}
// barRow — head (name + count) plus track.
// countDisplay overrides the rendered count text (e.g. "238" vs "238 events").
export function barRow({ name = '', count = 0, countDisplay, max = 0, fwClass = '', size = 'sm', modifier = '' } = {}) {
const display = countDisplay != null ? countDisplay : count;
return `
<div class="am-bar-row">
<div class="am-bar-row-head">
<span class="am-bar-row-name">${escapeHTML(String(name))}</span>
<span class="am-bar-row-count">${escapeHTML(String(display))}</span>
</div>
${barTrack({ value: count, max, fwClass, size, modifier })}
</div>`;
}
// barRankList — ranked list of barRows wrapped in <ul class="am-bar-list">.
// items: array of arbitrary objects
// mapItem: fn(item) → { name, count, countDisplay?, fwClass?, modifier? }
// maxOverride: ceiling override; default = max of items' counts
// size: bar height (default 'xs' for ranked lists)
// emptyText: shown when items is empty
export function barRankList(items, { mapItem, maxOverride, size = 'xs', emptyText = 'No data' } = {}) {
if (!items || items.length === 0) {
return `<p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">${escapeHTML(emptyText)}</p>`;
}
const mapped = items.map(item => (mapItem ? mapItem(item) : { name: item.name, count: item.count }));
const max = maxOverride != null ? maxOverride : Math.max(1, ...mapped.map(m => Number(m.count) || 0));
const rows = mapped.map(m => `<li>${barRow({ ...m, max, size })}</li>`).join('');
return `<ul class="am-bar-list">${rows}</ul>`;
}
// ── Pill primitives ──────────────────────────────────────
// metricPill — small label + bold value, optional meta line.
// value: pre-escaped HTML when valueHTML=true, else escaped as text
// valueId: id attribute (for animateCounter targets)
// variant: 'insight' | 'total' | 'range' | '' (default — standalone metric pill)
// alert: adds .alert to the pill (turns value red)
export function metricPill({ label, value = '-', valueId = '', valueHTML = false, meta = '', variant = '', alert = false } = {}) {
const classes = ['am-pill'];
if (variant) classes.push('am-pill--' + variant);
if (alert) classes.push('alert');
const idAttr = valueId ? ` id="${escapeHTML(valueId)}"` : '';
const rendered = valueHTML ? value : escapeHTML(String(value));
const metaLine = meta ? `<span class="am-pill-meta">${escapeHTML(String(meta))}</span>` : '';
return `
<div class="${classes.join(' ')}">
<span class="am-pill-label">${escapeHTML(String(label))}</span>
<strong class="am-pill-value"${idAttr}>${rendered}</strong>
${metaLine}
</div>`;
}
// metricStrip — flex/grid container around a list of pill specs.
// variant: '' | 'insights' (4-up grid) | 'totals' (tight inline pills)
export function metricStrip(pills, { variant = '', className = '' } = {}) {
const classes = ['am-pill-strip'];
if (variant) classes.push('am-pill-strip--' + variant);
if (className) classes.push(className);
return `<div class="${classes.join(' ')}">${pills.map(p => metricPill(p)).join('')}</div>`;
}
// ── Chart header ─────────────────────────────────────────
// chartHeader — title + optional subtitle on the left, raw controls HTML on the right.
// controls is passed through unchanged (legend/buttons/tabs vary by chart).
export function chartHeader({ title = '', subtitle = '', controls = '' } = {}) {
const subtitleHTML = subtitle ? `<span class="am-chart-subtitle">${escapeHTML(subtitle)}</span>` : '';
const controlsHTML = controls ? `<div class="am-chart-controls">${controls}</div>` : '';
return `
<div class="am-chart-header">
<div class="am-chart-title-group">
<span class="am-chart-title">${escapeHTML(title)}</span>
${subtitleHTML}
</div>
${controlsHTML}
</div>`;
}
+63 -145
View File
@@ -39,6 +39,13 @@ import {
import { clearErrorBadge } from '../palette.js'; import { clearErrorBadge } from '../palette.js';
import { app, navigate, isRouteCurrent } from '../router.js'; import { app, navigate, isRouteCurrent } from '../router.js';
import { api } from '../api.js'; import { api } from '../api.js';
import {
barRankList,
barRow,
metricPill,
metricStrip,
chartHeader,
} from '../components.js';
// uPlot is loaded as a global IIFE; access via window.uPlot // uPlot is loaded as a global IIFE; access via window.uPlot
/* global uPlot */ /* global uPlot */
@@ -182,7 +189,8 @@ function renderSummaryCards() {
const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0); const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0);
const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0; const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0;
animateCounter('dash-error-rate', rate.toFixed(1) + '%'); animateCounter('dash-error-rate', rate.toFixed(1) + '%');
errorRateEl.classList.toggle('alert', rate > 5); const pill = errorRateEl.closest('.am-pill');
if (pill) pill.classList.toggle('alert', rate > 5);
} }
if (document.getElementById('dash-cost-per-run')) { if (document.getElementById('dash-cost-per-run')) {
@@ -287,12 +295,12 @@ function renderDashboardChartInsights() {
} }
const peakBucket = dashboardState.timeseries.series[stats.peakIndex]; const peakBucket = dashboardState.timeseries.series[stats.peakIndex];
container.innerHTML = ` container.innerHTML = metricStrip([
<div class="chart-insight-pill"><span class="chart-insight-label">window total</span><strong>${escapeHTML(formatCount(stats.totalEvents))}</strong></div> { label: 'window total', value: formatCount(stats.totalEvents), variant: 'insight' },
<div class="chart-insight-pill"><span class="chart-insight-label">peak bucket</span><strong>${escapeHTML(formatCount(stats.peakTotal))}</strong><span class="chart-insight-meta">${escapeHTML(formatBucketLabel(peakBucket.ts))}</span></div> { label: 'peak bucket', value: formatCount(stats.peakTotal), meta: formatBucketLabel(peakBucket.ts), variant: 'insight' },
<div class="chart-insight-pill"><span class="chart-insight-label">mix</span><strong>${escapeHTML(formatCount(stats.totalRuns))}r / ${escapeHTML(formatCount(stats.totalTools))}t / ${escapeHTML(formatCount(stats.totalErrors))}e</strong></div> { label: 'mix', value: `${formatCount(stats.totalRuns)}r / ${formatCount(stats.totalTools)}t / ${formatCount(stats.totalErrors)}e`, variant: 'insight' },
<div class="chart-insight-pill"><span class="chart-insight-label">bucket</span><strong>${escapeHTML(dashboardState.timeseries.bucket || '-')}</strong><span class="chart-insight-meta">${escapeHTML(String(stats.bucketCount))} points</span></div> { label: 'bucket', value: dashboardState.timeseries.bucket || '-', meta: stats.bucketCount + ' points', variant: 'insight' },
`; ], { variant: 'insights' });
} }
function renderDashboardChartHover(idx) { function renderDashboardChartHover(idx) {
@@ -589,23 +597,11 @@ function renderTokenPanel() {
<div class="token-stat-value">${escapeHTML(formatTokenCount(totalTokens))}</div> <div class="token-stat-value">${escapeHTML(formatTokenCount(totalTokens))}</div>
</div> </div>
<div class="token-io-bars"> <div class="token-io-bars">
<div class="token-bar-row"> ${barRow({ name: 'Input', count: inputTokens, countDisplay: formatTokenCount(inputTokens), max: maxIO, modifier: 'input', size: 'md' })}
<span class="token-bar-label">Input</span> ${barRow({ name: 'Output', count: outputTokens, countDisplay: formatTokenCount(outputTokens), max: maxIO, modifier: 'output', size: 'md' })}
<div class="token-bar-track">
<div class="token-bar-fill input" style="width:${(inputTokens / maxIO * 100).toFixed(1)}%"></div>
</div>
<span class="token-bar-count">${escapeHTML(formatTokenCount(inputTokens))}</span>
</div>
<div class="token-bar-row">
<span class="token-bar-label">Output</span>
<div class="token-bar-track">
<div class="token-bar-fill output" style="width:${(outputTokens / maxIO * 100).toFixed(1)}%"></div>
</div>
<span class="token-bar-count">${escapeHTML(formatTokenCount(outputTokens))}</span>
</div>
</div> </div>
<div class="token-cost-display"> <div class="token-cost-display">
<span class="token-bar-label">Est. cost today</span> <span class="am-pill-label">Est. cost today</span>
<strong>${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')}</strong> <strong>${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')}</strong>
</div> </div>
</div> </div>
@@ -636,18 +632,9 @@ function renderLatencyPanel() {
container.innerHTML = ` container.innerHTML = `
<div class="latency-panel"> <div class="latency-panel">
<div class="latency-range"> <div class="latency-range">
<div class="latency-range-item"> ${metricPill({ label: 'Min', value: formatDuration(min), variant: 'range' })}
<span class="latency-range-label">Min</span> ${metricPill({ label: 'Avg', value: formatDuration(avg), variant: 'range' })}
<span class="latency-range-val">${escapeHTML(formatDuration(min))}</span> ${metricPill({ label: 'Max', value: formatDuration(max), variant: 'range' })}
</div>
<div class="latency-range-item">
<span class="latency-range-label">Avg</span>
<span class="latency-range-val">${escapeHTML(formatDuration(avg))}</span>
</div>
<div class="latency-range-item">
<span class="latency-range-label">Max</span>
<span class="latency-range-val">${escapeHTML(formatDuration(max))}</span>
</div>
</div> </div>
<div class="latency-mini-bars"> <div class="latency-mini-bars">
${durSeries.map((v, i) => { ${durSeries.map((v, i) => {
@@ -657,7 +644,7 @@ function renderLatencyPanel() {
return `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`; return `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`;
}).join('')} }).join('')}
</div> </div>
<div class="latency-range-label" style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim)">Avg run duration per bucket (${escapeHTML(ts.bucket || '-')})</div> <div class="am-pill-label" style="margin-top:0.5rem">Avg run duration per bucket (${escapeHTML(ts.bucket || '-')})</div>
</div> </div>
`; `;
} }
@@ -680,21 +667,17 @@ function renderFrameworkBars() {
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors)); const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors));
container.innerHTML = '<div class="fw-bars">' + entries.map(([name, stats]) => { container.innerHTML = '<div style="display:flex;flex-direction:column;gap:0.75rem;margin-top:0.25rem">' + entries.map(([name, stats]) => {
const total = stats.runs + stats.tools + stats.errors; const total = stats.runs + stats.tools + stats.errors;
const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
return ` return barRow({
<div class="fw-bar-row"> name,
<div class="fw-bar-label"> count: total,
<span class="fw-bar-name">${escapeHTML(name)}</span> countDisplay: total + ' events',
<span class="fw-bar-count">${total} events</span> max: maxTotal,
</div> fwClass: cssClass,
<div class="fw-bar-track"> size: 'lg',
<div class="fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div> });
</div>
</div>
`;
}).join('') + '</div>'; }).join('') + '</div>';
} }
@@ -763,61 +746,21 @@ function renderDashFeed() {
function renderDashTopTools() { function renderDashTopTools() {
const list = document.getElementById('dash-top-tools'); const list = document.getElementById('dash-top-tools');
if (!list) return; if (!list) return;
const topTools = Object.entries(dashboardState.toolCounts) const topTools = Object.entries(dashboardState.toolCounts)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 10); .slice(0, 10)
.map(([name, count]) => ({ name, count }));
if (topTools.length === 0) { list.innerHTML = barRankList(topTools, { emptyText: 'No tool data yet' });
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No tool data yet</li>';
return;
}
const maxCount = topTools[0]?.[1] || 1;
list.innerHTML = topTools.map(([name, count]) => {
const pct = (count / maxCount * 100).toFixed(1);
return `
<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(name)}</span>
<span class="stat-list-count">${count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill" style="width:${pct}%"></div>
</div>
</li>
`;
}).join('');
} }
function renderDashTopModels() { function renderDashTopModels() {
const list = document.getElementById('dash-top-models'); const list = document.getElementById('dash-top-models');
if (!list) return; if (!list) return;
const topModels = Object.entries(dashboardState.modelCounts) const topModels = Object.entries(dashboardState.modelCounts)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 10); .slice(0, 10)
.map(([name, count]) => ({ name, count, modifier: 'model' }));
if (topModels.length === 0) { list.innerHTML = barRankList(topModels, { emptyText: 'No model data yet' });
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No model data yet</li>';
return;
}
const maxCount = topModels[0]?.[1] || 1;
list.innerHTML = topModels.map(([name, count]) => {
const pct = (count / maxCount * 100).toFixed(1);
return `
<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(name)}</span>
<span class="stat-list-count">${count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
</div>
</li>
`;
}).join('');
} }
// ── Exports ────────────────────────────────────────────── // ── Exports ──────────────────────────────────────────────
@@ -863,38 +806,21 @@ export async function renderDashboard(routeToken) {
<div class="summary-card-sub" id="dash-errors-sub">&nbsp;</div> <div class="summary-card-sub" id="dash-errors-sub">&nbsp;</div>
</div> </div>
</div> </div>
<div class="metrics-strip"> <div style="margin-bottom:1.25rem">${metricStrip([
<div class="metric-pill"> { label: 'Tokens today', valueId: 'dash-tokens-today' },
<span class="metric-pill-label">Tokens today</span> { label: 'Cost today', valueId: 'dash-cost-today' },
<span class="metric-pill-value" id="dash-tokens-today">-</span> { label: 'Avg run duration', valueId: 'dash-avg-duration' },
</div> { label: 'Error rate', valueId: 'dash-error-rate' },
<div class="metric-pill"> { label: 'Cost / run', valueId: 'dash-cost-per-run' },
<span class="metric-pill-label">Cost today</span> ])}</div>
<span class="metric-pill-value" id="dash-cost-today">-</span>
</div>
<div class="metric-pill">
<span class="metric-pill-label">Avg run duration</span>
<span class="metric-pill-value" id="dash-avg-duration">-</span>
</div>
<div class="metric-pill">
<span class="metric-pill-label">Error rate</span>
<span class="metric-pill-value metric-pill-alert" id="dash-error-rate">-</span>
</div>
<div class="metric-pill">
<span class="metric-pill-label">Cost / run</span>
<span class="metric-pill-value" id="dash-cost-per-run">-</span>
</div>
</div>
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div> <div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
<div class="vm-strip" id="dash-vm-strip"></div> <div class="vm-strip" id="dash-vm-strip"></div>
<div class="charts-row"> <div class="charts-row">
<div class="chart-panel"> <div class="chart-panel">
<div class="chart-header"> ${chartHeader({
<div class="chart-title-group"> title: 'Event Rate',
<span class="chart-title">Event Rate</span> subtitle: 'Runs, tool spans, and errors over time',
<span class="chart-subtitle">Runs, tool spans, and errors over time</span> controls: `
</div>
<div class="chart-header-controls">
<div class="chart-legend"> <div class="chart-legend">
<span class="chart-legend-item"><span class="chart-legend-dot total"></span>total</span> <span class="chart-legend-item"><span class="chart-legend-dot total"></span>total</span>
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#34d399"></span>runs</span> <span class="chart-legend-item"><span class="chart-legend-dot" style="background:#34d399"></span>runs</span>
@@ -910,21 +836,21 @@ export async function renderDashboard(routeToken) {
<button class="window-btn" data-w="6h">6h</button> <button class="window-btn" data-w="6h">6h</button>
<button class="window-btn" data-w="24h">24h</button> <button class="window-btn" data-w="24h">24h</button>
<button class="window-btn" data-w="7d">7d</button> <button class="window-btn" data-w="7d">7d</button>
</div> </div>`,
</div> })}
</div> <div id="dash-chart-insights" style="margin-bottom:0.9rem"></div>
<div class="chart-insights" id="dash-chart-insights"></div>
<div class="chart-container" id="dash-chart"></div> <div class="chart-container" id="dash-chart"></div>
<div class="chart-hover-panel" id="dash-chart-hover"></div> <div class="chart-hover-panel" id="dash-chart-hover"></div>
</div> </div>
<div class="chart-panel right-panel"> <div class="chart-panel right-panel">
<div class="chart-header"> ${chartHeader({
<div class="right-panel-tabs" id="dash-right-tabs"> controls: `
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button> <div class="right-panel-tabs" id="dash-right-tabs">
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button> <button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button> <button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
</div> <button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
</div> </div>`,
})}
<div class="right-panel-body" id="dash-right-panel"> <div class="right-panel-body" id="dash-right-panel">
<p class="empty-state" style="padding:1rem">Loading...</p> <p class="empty-state" style="padding:1rem">Loading...</p>
</div> </div>
@@ -932,28 +858,20 @@ export async function renderDashboard(routeToken) {
</div> </div>
<div class="bottom-panels"> <div class="bottom-panels">
<div class="feed-panel"> <div class="feed-panel">
<div class="chart-header"> ${chartHeader({ title: 'Recent Activity' })}
<span class="chart-title">Recent Activity</span>
</div>
<div class="timeline" id="dash-feed"> <div class="timeline" id="dash-feed">
<p class="empty-state" style="padding:1rem">Loading...</p> <p class="empty-state" style="padding:1rem">Loading...</p>
</div> </div>
</div> </div>
<div class="tools-panel"> <div class="tools-panel">
<div class="chart-header"> ${chartHeader({ title: 'Top Usage' })}
<span class="chart-title">Top Usage</span>
</div>
<div class="usage-rank-group"> <div class="usage-rank-group">
<div class="usage-rank-header">Tools</div> <div class="usage-rank-header">Tools</div>
<ul class="stat-list" id="dash-top-tools"> <div id="dash-top-tools"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
</ul>
</div> </div>
<div class="usage-rank-group"> <div class="usage-rank-group">
<div class="usage-rank-header">Models</div> <div class="usage-rank-header">Models</div>
<ul class="stat-list" id="dash-top-models"> <div id="dash-top-models"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
+28 -64
View File
@@ -1,6 +1,7 @@
import { app, isRouteCurrent } from '../router.js'; import { app, isRouteCurrent } from '../router.js';
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js'; import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
import { barTrack, barRankList, metricStrip, chartHeader } from '../components.js';
/* global uPlot */ /* global uPlot */
@@ -104,7 +105,6 @@ function renderFrameworkBreakdown(byFw) {
el.innerHTML = entries.map(([name, stats]) => { el.innerHTML = entries.map(([name, stats]) => {
const total = stats.runs + stats.tools + stats.errors; 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 cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const active = (stats.active_sessions || 0) > 0; const active = (stats.active_sessions || 0) > 0;
return ` return `
@@ -119,9 +119,7 @@ function renderFrameworkBreakdown(byFw) {
<span class="usage-fw-stat"><span class="usage-fw-stat-label">tools</span>${stats.tools || 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>` : ''} ${(stats.errors || 0) > 0 ? `<span class="usage-fw-stat error"><span class="usage-fw-stat-label">err</span>${stats.errors}</span>` : ''}
</div> </div>
<div class="usage-fw-bar-track"> ${barTrack({ value: total, max: maxTotal, fwClass: cssClass, size: 'sm' })}
<div class="usage-fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
</div>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -194,36 +192,22 @@ export async function renderUsage(routeToken) {
<div class="usage-section-row"> <div class="usage-section-row">
<div class="usage-panel usage-chart-panel"> <div class="usage-panel usage-chart-panel">
<div class="usage-chart-header"> ${chartHeader({
<span class="section-title" style="margin:0">7-Day Trend</span> title: '7-Day Trend',
<div class="usage-chart-tabs" id="usage-chart-tabs"> controls: `
<button class="usage-chart-tab active" data-mode="activity">Activity</button> <div class="usage-chart-tabs" id="usage-chart-tabs">
<button class="usage-chart-tab" data-mode="tokens">Tokens</button> <button class="usage-chart-tab active" data-mode="activity">Activity</button>
<button class="usage-chart-tab" data-mode="cost">Cost</button> <button class="usage-chart-tab" data-mode="tokens">Tokens</button>
</div> <button class="usage-chart-tab" data-mode="cost">Cost</button>
</div> </div>`,
<div class="usage-chart-totals"> })}
<span class="usage-chart-total-pill"> ${metricStrip([
<span class="usage-chart-total-label">runs</span> { label: 'runs', value: String(t.runs), variant: 'total' },
<strong>${t.runs}</strong> { label: 'tools', value: String(t.tools), variant: 'total' },
</span> { label: 'errors', value: String(t.errors), variant: 'total', alert: t.errors > 0 },
<span class="usage-chart-total-pill"> { label: 'tokens', value: formatTokenCount(t.tokens), variant: 'total' },
<span class="usage-chart-total-label">tools</span> { label: 'cost', value: formatCost(t.cost), variant: 'total' },
<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 id="usage-chart"></div>
</div> </div>
<div class="usage-panel usage-fw-panel"> <div class="usage-panel usage-fw-panel">
@@ -238,40 +222,20 @@ export async function renderUsage(routeToken) {
<div class="usage-section-row"> <div class="usage-section-row">
<div class="usage-panel"> <div class="usage-panel">
<div class="section-title">Top Models <span class="count">${models.length}</span></div> <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>' : ` ${barRankList(models, {
<ul class="stat-list"> mapItem: m => ({ name: m.name, count: m.count, modifier: 'model' }),
${models.map(m => { maxOverride: maxModel,
const pct = (m.count / maxModel * 100).toFixed(1); emptyText: 'No model data',
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>
<div class="usage-panel"> <div class="usage-panel">
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div> <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>' : ` ${barRankList(tools, {
<ul class="stat-list"> mapItem: x => ({ name: x.name, count: x.count }),
${tools.map(t => { maxOverride: maxTool,
const pct = (t.count / maxTool * 100).toFixed(1); emptyText: 'No tool data',
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>
</div> </div>
`; `;
+191 -329
View File
@@ -1416,56 +1416,214 @@ tr.expandable:hover .expand-icon::before {
margin-top: 0.1rem; margin-top: 0.1rem;
} }
.stat-list { /* ============================================================
list-style: none; Shared primitives (am-*): bars, pills, chart headers.
} See modules/components.js for the renderers.
============================================================ */
.stat-list li { /* ── Bars ─────────────────────────────────────────────── */
.am-bar-list {
list-style: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.am-bar-list li {
padding: 0.35rem 0; padding: 0.35rem 0;
border-bottom: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft);
font-size: 0.8rem; font-size: 0.8rem;
} }
.stat-list li:last-child { .am-bar-list li:last-child { border-bottom: none; }
border-bottom: none;
.am-bar-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
} }
.stat-list-name { .am-bar-row-head {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text);
}
.stat-list-count {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
background: var(--surface-2);
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
.stat-list-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.stat-list-bar-track { .am-bar-row-name {
height: 3px; font-family: var(--font-mono);
background: var(--surface-2); font-size: 0.78rem;
border-radius: 2px; color: var(--text);
margin-top: 0.3rem;
overflow: hidden;
} }
.stat-list-bar-fill { .am-bar-row-count {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
}
.am-bar-track {
background: var(--surface-2);
border-radius: 3px;
overflow: hidden;
height: 6px;
}
.am-bar-track--xs { height: 3px; border-radius: 2px; }
.am-bar-track--sm { height: 4px; border-radius: 2px; }
.am-bar-track--md { height: 6px; border-radius: 3px; }
.am-bar-track--lg { height: 8px; border-radius: 4px; }
.am-bar-fill {
height: 100%; height: 100%;
background: var(--accent); background: var(--accent);
border-radius: 2px; transition: width 0.4s ease;
transition: width 0.3s ease; }
/* Framework / category color modifiers (applied to .am-bar-fill) */
.am-bar-fill.fw-openclaw { background: var(--accent); }
.am-bar-fill.fw-claude-code { background: var(--success); }
.am-bar-fill.fw-opencode { background: var(--purple); }
.am-bar-fill.fw-hermes { background: var(--warning); }
.am-bar-fill.fw-codex { background: #60a5fa; }
.am-bar-fill.fw-gemini { background: #f97316; }
.am-bar-fill.fw-copilot { background: #2dd4bf; }
.am-bar-fill.fw-unknown { background: var(--text-dim); }
.am-bar-fill.model { background: var(--success); }
.am-bar-fill.input { background: var(--accent); }
.am-bar-fill.output { background: var(--purple); }
/* ── Pills ────────────────────────────────────────────── */
.am-pill-strip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.am-pill-strip--insights {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 900px) {
.am-pill-strip--insights { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.am-pill-strip--insights { grid-template-columns: 1fr; }
}
.am-pill {
display: flex;
flex-direction: column;
gap: 0.15rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.85rem;
min-width: 130px;
flex: 1;
}
.am-pill-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
font-family: var(--font-mono);
}
.am-pill-value {
font-size: 1.05rem;
font-weight: 600;
font-family: var(--font-mono);
color: var(--text-bright);
}
.am-pill-meta {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-dim);
letter-spacing: 0.03em;
text-transform: uppercase;
}
.am-pill.alert .am-pill-value { color: var(--error); }
/* Variant: chart insight (taller, gradient background) */
.am-pill--insight {
min-height: 62px;
border-color: var(--border-soft);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0));
padding: 0.75rem 0.85rem;
gap: 0.25rem;
}
.am-pill--insight .am-pill-value { font-size: 0.95rem; }
/* Variant: tight inline total (e.g. usage chart totals) */
.am-pill--total {
flex-direction: row;
align-items: baseline;
gap: 0.3rem;
background: var(--surface-2);
border-color: var(--border-soft);
border-radius: var(--radius);
padding: 0.22rem 0.55rem;
min-width: auto;
flex: 0 0 auto;
}
.am-pill--total .am-pill-label { font-size: 0.6rem; }
.am-pill--total .am-pill-value { font-size: 0.8rem; font-weight: normal; }
/* Variant: latency-range item (centered, borderless) */
.am-pill--range {
flex: 0 0 auto;
min-width: auto;
align-items: center;
gap: 0.1rem;
background: transparent;
border: none;
padding: 0;
}
.am-pill--range .am-pill-label { font-size: 0.68rem; }
.am-pill--range .am-pill-value { font-size: 1rem; font-weight: 600; color: var(--text); }
/* ── Chart headers ────────────────────────────────────── */
.am-chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.am-chart-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.am-chart-title {
font-family: var(--font-display);
font-size: 0.88rem;
font-weight: 700;
color: var(--text-bright);
letter-spacing: 0.01em;
}
.am-chart-subtitle {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.02em;
}
.am-chart-controls {
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
justify-content: flex-end;
} }
.event-icon { .event-icon {
@@ -1592,7 +1750,7 @@ tr.expandable:hover .expand-icon::before {
100% { transform: scale(1); } 100% { transform: scale(1); }
} }
.metric-pill-value.bumped { .am-pill-value.bumped {
animation: counterBump 400ms ease; animation: counterBump 400ms ease;
} }
@@ -1635,44 +1793,6 @@ tr.expandable:hover .expand-icon::before {
min-height: 280px; min-height: 280px;
} }
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.chart-title {
font-family: var(--font-display);
font-size: 0.88rem;
font-weight: 700;
color: var(--text-bright);
letter-spacing: 0.01em;
}
.chart-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.chart-subtitle {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.02em;
}
.chart-header-controls {
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.window-selector { .window-selector {
display: flex; display: flex;
gap: 0; gap: 0;
@@ -1746,50 +1866,6 @@ tr.expandable:hover .expand-icon::before {
background: rgba(52, 211, 153, 0.12); background: rgba(52, 211, 153, 0.12);
} }
.chart-insights {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.9rem;
}
@media (max-width: 900px) {
.chart-insights {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.chart-insights {
grid-template-columns: 1fr;
}
}
.chart-insight-pill {
min-height: 62px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0));
padding: 0.75rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.chart-insight-label,
.chart-insight-meta {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-dim);
letter-spacing: 0.03em;
text-transform: uppercase;
}
.chart-insight-pill strong {
font-size: 0.95rem;
color: var(--text-bright);
}
.chart-container { .chart-container {
width: 100%; width: 100%;
min-height: 200px; min-height: 200px;
@@ -1869,59 +1945,6 @@ tr.expandable:hover .expand-icon::before {
.chart-hover-metric.errors strong { color: #f87171; } .chart-hover-metric.errors strong { color: #f87171; }
.chart-hover-metric.delta strong { color: var(--accent); } .chart-hover-metric.delta strong { color: var(--accent); }
.fw-bars {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.25rem;
}
.fw-bar-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.fw-bar-label {
display: flex;
justify-content: space-between;
align-items: center;
}
.fw-bar-name {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text);
}
.fw-bar-count {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
}
.fw-bar-track {
height: 8px;
background: var(--surface-2);
border-radius: 4px;
overflow: hidden;
}
.fw-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.4s ease;
}
.fw-bar-fill.openclaw { background: var(--accent); }
.fw-bar-fill.claude-code { background: var(--success); }
.fw-bar-fill.opencode { background: var(--purple); }
.fw-bar-fill.hermes { background: var(--warning); }
.fw-bar-fill.codex { background: #60a5fa; }
.fw-bar-fill.gemini { background: #f97316; }
.fw-bar-fill.copilot { background: #2dd4bf; }
.fw-bar-fill.unknown { background: var(--text-dim); }
.bottom-panels { .bottom-panels {
display: grid; display: grid;
grid-template-columns: 1fr 320px; grid-template-columns: 1fr 320px;
@@ -2017,10 +2040,6 @@ tr.expandable:hover .expand-icon::before {
box-shadow: 0 0 0 1px rgba(248, 250, 252, 0.2); box-shadow: 0 0 0 1px rgba(248, 250, 252, 0.2);
} }
.stat-list-bar-fill.model {
background: var(--success);
}
/* ── Framework dots ───────────────────────────────────────── */ /* ── Framework dots ───────────────────────────────────────── */
.fw-dot { .fw-dot {
display: inline-block; display: inline-block;
@@ -3122,44 +3141,6 @@ tr.clickable.active-session td:first-child {
margin: 0; margin: 0;
} }
/* ── Metrics strip ────────────────────────────────────────── */
.metrics-strip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.metric-pill {
display: flex;
flex-direction: column;
gap: 0.15rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.85rem;
min-width: 130px;
flex: 1;
}
.metric-pill-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.metric-pill-value {
font-size: 1.05rem;
font-weight: 600;
font-family: var(--font-mono);
color: var(--text);
}
.metric-pill-alert.alert {
color: var(--error);
}
/* ── Right panel tabs ─────────────────────────────────────── */ /* ── Right panel tabs ─────────────────────────────────────── */
.right-panel-tabs { .right-panel-tabs {
display: flex; display: flex;
@@ -3229,47 +3210,6 @@ tr.clickable.active-session td:first-child {
gap: 0.5rem; gap: 0.5rem;
} }
.token-bar-row {
display: grid;
grid-template-columns: 3.5rem 1fr 3rem;
align-items: center;
gap: 0.5rem;
}
.token-bar-label {
font-size: 0.72rem;
color: var(--text-dim);
text-align: right;
}
.token-bar-track {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.token-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.token-bar-fill.input {
background: var(--accent);
}
.token-bar-fill.output {
background: var(--purple, #a78bfa);
}
.token-bar-count {
font-size: 0.72rem;
font-family: 'Fira Code', monospace;
color: var(--text-muted, var(--text-dim));
text-align: right;
}
.token-cost-display { .token-cost-display {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -3298,27 +3238,6 @@ tr.clickable.active-session td:first-child {
justify-content: space-between; justify-content: space-between;
} }
.latency-range-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
.latency-range-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.latency-range-val {
font-size: 1rem;
font-weight: 600;
font-family: 'Fira Code', monospace;
color: var(--text);
}
.latency-mini-bars { .latency-mini-bars {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -3640,7 +3559,7 @@ tr.clickable.active-session td:first-child {
display: none; display: none;
} }
.chart-header-controls { .am-chart-controls {
justify-content: flex-start; justify-content: flex-start;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -3944,15 +3863,6 @@ tr.clickable:focus-visible {
.usage-chart-panel { flex: 2 1 400px; } .usage-chart-panel { flex: 2 1 400px; }
.usage-fw-panel { flex: 1 1 240px; } .usage-fw-panel { flex: 1 1 240px; }
.usage-chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.usage-chart-tabs { .usage-chart-tabs {
display: flex; display: flex;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -3976,33 +3886,6 @@ tr.clickable:focus-visible {
.usage-chart-tab:hover { color: var(--text-bright); background: var(--surface-2); } .usage-chart-tab:hover { color: var(--text-bright); background: var(--surface-2); }
.usage-chart-tab.active { color: var(--accent); background: var(--accent-dim); } .usage-chart-tab.active { color: var(--accent); background: var(--accent-dim); }
.usage-chart-totals {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.usage-chart-total-pill {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
background: var(--surface-2);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
padding: 0.22rem 0.55rem;
font-family: var(--font-mono);
font-size: 0.8rem;
}
.usage-chart-total-label {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.usage-chart-total-pill strong { color: var(--text-bright); }
.usage-total-errors { color: var(--error); }
/* ── Usage Page: Framework Breakdown ─────────────────────── */ /* ── Usage Page: Framework Breakdown ─────────────────────── */
.usage-fw-row { .usage-fw-row {
display: flex; display: flex;
@@ -4056,27 +3939,6 @@ tr.clickable:focus-visible {
color: var(--text-dim); color: var(--text-dim);
} }
.usage-fw-bar-track {
height: 4px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.usage-fw-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
background: var(--text-dim);
}
.usage-fw-bar-fill.openclaw { background: var(--accent); }
.usage-fw-bar-fill.claude-code { background: var(--success); }
.usage-fw-bar-fill.opencode { background: var(--purple); }
.usage-fw-bar-fill.hermes { background: var(--warning); }
.usage-fw-bar-fill.codex { background: #60a5fa; }
.usage-fw-bar-fill.gemini { background: #f97316; }
.usage-fw-bar-fill.copilot { background: #2dd4bf; }
/* ── Run Detail: Prompt Preview ──────────────────────────── */ /* ── Run Detail: Prompt Preview ──────────────────────────── */
.prompt-preview-section { .prompt-preview-section {
background: var(--surface); background: var(--surface);