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:
@@ -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>`;
|
||||
}
|
||||
@@ -39,6 +39,13 @@ import {
|
||||
import { clearErrorBadge } from '../palette.js';
|
||||
import { app, navigate, isRouteCurrent } from '../router.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
|
||||
/* global uPlot */
|
||||
@@ -182,7 +189,8 @@ function renderSummaryCards() {
|
||||
const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0);
|
||||
const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0;
|
||||
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')) {
|
||||
@@ -287,12 +295,12 @@ function renderDashboardChartInsights() {
|
||||
}
|
||||
|
||||
const peakBucket = dashboardState.timeseries.series[stats.peakIndex];
|
||||
container.innerHTML = `
|
||||
<div class="chart-insight-pill"><span class="chart-insight-label">window total</span><strong>${escapeHTML(formatCount(stats.totalEvents))}</strong></div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
`;
|
||||
container.innerHTML = metricStrip([
|
||||
{ label: 'window total', value: formatCount(stats.totalEvents), variant: 'insight' },
|
||||
{ label: 'peak bucket', value: formatCount(stats.peakTotal), meta: formatBucketLabel(peakBucket.ts), variant: 'insight' },
|
||||
{ label: 'mix', value: `${formatCount(stats.totalRuns)}r / ${formatCount(stats.totalTools)}t / ${formatCount(stats.totalErrors)}e`, variant: 'insight' },
|
||||
{ label: 'bucket', value: dashboardState.timeseries.bucket || '-', meta: stats.bucketCount + ' points', variant: 'insight' },
|
||||
], { variant: 'insights' });
|
||||
}
|
||||
|
||||
function renderDashboardChartHover(idx) {
|
||||
@@ -589,23 +597,11 @@ function renderTokenPanel() {
|
||||
<div class="token-stat-value">${escapeHTML(formatTokenCount(totalTokens))}</div>
|
||||
</div>
|
||||
<div class="token-io-bars">
|
||||
<div class="token-bar-row">
|
||||
<span class="token-bar-label">Input</span>
|
||||
<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>
|
||||
${barRow({ name: 'Input', count: inputTokens, countDisplay: formatTokenCount(inputTokens), max: maxIO, modifier: 'input', size: 'md' })}
|
||||
${barRow({ name: 'Output', count: outputTokens, countDisplay: formatTokenCount(outputTokens), max: maxIO, modifier: 'output', size: 'md' })}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -636,18 +632,9 @@ function renderLatencyPanel() {
|
||||
container.innerHTML = `
|
||||
<div class="latency-panel">
|
||||
<div class="latency-range">
|
||||
<div class="latency-range-item">
|
||||
<span class="latency-range-label">Min</span>
|
||||
<span class="latency-range-val">${escapeHTML(formatDuration(min))}</span>
|
||||
</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>
|
||||
${metricPill({ label: 'Min', value: formatDuration(min), variant: 'range' })}
|
||||
${metricPill({ label: 'Avg', value: formatDuration(avg), variant: 'range' })}
|
||||
${metricPill({ label: 'Max', value: formatDuration(max), variant: 'range' })}
|
||||
</div>
|
||||
<div class="latency-mini-bars">
|
||||
${durSeries.map((v, i) => {
|
||||
@@ -657,7 +644,7 @@ function renderLatencyPanel() {
|
||||
return `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`;
|
||||
}).join('')}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
@@ -680,21 +667,17 @@ function renderFrameworkBars() {
|
||||
|
||||
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 pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
|
||||
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
return `
|
||||
<div class="fw-bar-row">
|
||||
<div class="fw-bar-label">
|
||||
<span class="fw-bar-name">${escapeHTML(name)}</span>
|
||||
<span class="fw-bar-count">${total} events</span>
|
||||
</div>
|
||||
<div class="fw-bar-track">
|
||||
<div class="fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return barRow({
|
||||
name,
|
||||
count: total,
|
||||
countDisplay: total + ' events',
|
||||
max: maxTotal,
|
||||
fwClass: cssClass,
|
||||
size: 'lg',
|
||||
});
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
@@ -763,61 +746,21 @@ function renderDashFeed() {
|
||||
function renderDashTopTools() {
|
||||
const list = document.getElementById('dash-top-tools');
|
||||
if (!list) return;
|
||||
|
||||
const topTools = Object.entries(dashboardState.toolCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
|
||||
if (topTools.length === 0) {
|
||||
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('');
|
||||
.slice(0, 10)
|
||||
.map(([name, count]) => ({ name, count }));
|
||||
list.innerHTML = barRankList(topTools, { emptyText: 'No tool data yet' });
|
||||
}
|
||||
|
||||
function renderDashTopModels() {
|
||||
const list = document.getElementById('dash-top-models');
|
||||
if (!list) return;
|
||||
|
||||
const topModels = Object.entries(dashboardState.modelCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
|
||||
if (topModels.length === 0) {
|
||||
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('');
|
||||
.slice(0, 10)
|
||||
.map(([name, count]) => ({ name, count, modifier: 'model' }));
|
||||
list.innerHTML = barRankList(topModels, { emptyText: 'No model data yet' });
|
||||
}
|
||||
|
||||
// ── Exports ──────────────────────────────────────────────
|
||||
@@ -863,38 +806,21 @@ export async function renderDashboard(routeToken) {
|
||||
<div class="summary-card-sub" id="dash-errors-sub"> </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics-strip">
|
||||
<div class="metric-pill">
|
||||
<span class="metric-pill-label">Tokens today</span>
|
||||
<span class="metric-pill-value" id="dash-tokens-today">-</span>
|
||||
</div>
|
||||
<div class="metric-pill">
|
||||
<span class="metric-pill-label">Cost today</span>
|
||||
<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 style="margin-bottom:1.25rem">${metricStrip([
|
||||
{ label: 'Tokens today', valueId: 'dash-tokens-today' },
|
||||
{ label: 'Cost today', valueId: 'dash-cost-today' },
|
||||
{ label: 'Avg run duration', valueId: 'dash-avg-duration' },
|
||||
{ label: 'Error rate', valueId: 'dash-error-rate' },
|
||||
{ label: 'Cost / run', valueId: 'dash-cost-per-run' },
|
||||
])}</div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
||||
<div class="vm-strip" id="dash-vm-strip"></div>
|
||||
<div class="charts-row">
|
||||
<div class="chart-panel">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title-group">
|
||||
<span class="chart-title">Event Rate</span>
|
||||
<span class="chart-subtitle">Runs, tool spans, and errors over time</span>
|
||||
</div>
|
||||
<div class="chart-header-controls">
|
||||
${chartHeader({
|
||||
title: 'Event Rate',
|
||||
subtitle: 'Runs, tool spans, and errors over time',
|
||||
controls: `
|
||||
<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" 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="24h">24h</button>
|
||||
<button class="window-btn" data-w="7d">7d</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-insights" id="dash-chart-insights"></div>
|
||||
</div>`,
|
||||
})}
|
||||
<div id="dash-chart-insights" style="margin-bottom:0.9rem"></div>
|
||||
<div class="chart-container" id="dash-chart"></div>
|
||||
<div class="chart-hover-panel" id="dash-chart-hover"></div>
|
||||
</div>
|
||||
<div class="chart-panel right-panel">
|
||||
<div class="chart-header">
|
||||
<div class="right-panel-tabs" id="dash-right-tabs">
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
|
||||
</div>
|
||||
</div>
|
||||
${chartHeader({
|
||||
controls: `
|
||||
<div class="right-panel-tabs" id="dash-right-tabs">
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
|
||||
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
|
||||
</div>`,
|
||||
})}
|
||||
<div class="right-panel-body" id="dash-right-panel">
|
||||
<p class="empty-state" style="padding:1rem">Loading...</p>
|
||||
</div>
|
||||
@@ -932,28 +858,20 @@ export async function renderDashboard(routeToken) {
|
||||
</div>
|
||||
<div class="bottom-panels">
|
||||
<div class="feed-panel">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">Recent Activity</span>
|
||||
</div>
|
||||
${chartHeader({ title: 'Recent Activity' })}
|
||||
<div class="timeline" id="dash-feed">
|
||||
<p class="empty-state" style="padding:1rem">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tools-panel">
|
||||
<div class="chart-header">
|
||||
<span class="chart-title">Top Usage</span>
|
||||
</div>
|
||||
${chartHeader({ title: 'Top Usage' })}
|
||||
<div class="usage-rank-group">
|
||||
<div class="usage-rank-header">Tools</div>
|
||||
<ul class="stat-list" id="dash-top-tools">
|
||||
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
|
||||
</ul>
|
||||
<div id="dash-top-tools"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
|
||||
</div>
|
||||
<div class="usage-rank-group">
|
||||
<div class="usage-rank-header">Models</div>
|
||||
<ul class="stat-list" id="dash-top-models">
|
||||
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
|
||||
</ul>
|
||||
<div id="dash-top-models"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 */
|
||||
|
||||
@@ -104,7 +105,6 @@ function renderFrameworkBreakdown(byFw) {
|
||||
|
||||
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 `
|
||||
@@ -119,9 +119,7 @@ function renderFrameworkBreakdown(byFw) {
|
||||
<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>
|
||||
${barTrack({ value: total, max: maxTotal, fwClass: cssClass, size: 'sm' })}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -194,36 +192,22 @@ export async function renderUsage(routeToken) {
|
||||
|
||||
<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>
|
||||
${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">
|
||||
@@ -238,40 +222,20 @@ export async function renderUsage(routeToken) {
|
||||
<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>`}
|
||||
${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>
|
||||
${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>`}
|
||||
${barRankList(tools, {
|
||||
mapItem: x => ({ name: x.name, count: x.count }),
|
||||
maxOverride: maxTool,
|
||||
emptyText: 'No tool data',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
+191
-329
@@ -1416,56 +1416,214 @@ tr.expandable:hover .expand-icon::before {
|
||||
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;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.am-bar-list li {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-list li:last-child {
|
||||
border-bottom: none;
|
||||
.am-bar-list li:last-child { border-bottom: none; }
|
||||
|
||||
.am-bar-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.stat-list-name {
|
||||
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 {
|
||||
.am-bar-row-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-list-bar-track {
|
||||
height: 3px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 2px;
|
||||
margin-top: 0.3rem;
|
||||
overflow: hidden;
|
||||
.am-bar-row-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.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%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
transition: width 0.4s 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 {
|
||||
@@ -1592,7 +1750,7 @@ tr.expandable:hover .expand-icon::before {
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.metric-pill-value.bumped {
|
||||
.am-pill-value.bumped {
|
||||
animation: counterBump 400ms ease;
|
||||
}
|
||||
|
||||
@@ -1635,44 +1793,6 @@ tr.expandable:hover .expand-icon::before {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
@@ -1746,50 +1866,6 @@ tr.expandable:hover .expand-icon::before {
|
||||
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 {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
@@ -1869,59 +1945,6 @@ tr.expandable:hover .expand-icon::before {
|
||||
.chart-hover-metric.errors strong { color: #f87171; }
|
||||
.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 {
|
||||
display: grid;
|
||||
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);
|
||||
}
|
||||
|
||||
.stat-list-bar-fill.model {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
/* ── Framework dots ───────────────────────────────────────── */
|
||||
.fw-dot {
|
||||
display: inline-block;
|
||||
@@ -3122,44 +3141,6 @@ tr.clickable.active-session td:first-child {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -3229,47 +3210,6 @@ tr.clickable.active-session td:first-child {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -3298,27 +3238,6 @@ tr.clickable.active-session td:first-child {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -3640,7 +3559,7 @@ tr.clickable.active-session td:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-header-controls {
|
||||
.am-chart-controls {
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -3944,15 +3863,6 @@ tr.clickable:focus-visible {
|
||||
.usage-chart-panel { flex: 2 1 400px; }
|
||||
.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 {
|
||||
display: flex;
|
||||
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.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-fw-row {
|
||||
display: flex;
|
||||
@@ -4056,27 +3939,6 @@ tr.clickable:focus-visible {
|
||||
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 ──────────────────────────── */
|
||||
.prompt-preview-section {
|
||||
background: var(--surface);
|
||||
|
||||
Reference in New Issue
Block a user