feat(web-ui): better stats and ergonomics
Usage page: add 7-day trend chart (activity/tokens/cost tabs), framework breakdown panel with per-framework run/tool/error counts and proportional bars, and 7d aggregate pills above the chart. Dashboard: add avg cost/run metric pill to the metrics strip. Run detail: extract and display prompt preview from the first agent span's payload above the spans table. Bug fixes: stat-list bars now render correctly (flex-direction:column), right-panel-tab active background uses correct accent color, missing framework colors added for hermes/codex/gemini/copilot. Dead code renderSessionRow removed from sessions.js. Hardcoded font-family replaced with CSS variable in metric-pill-value and token-stat-value. Usage page cleanup() wired into router teardown. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -184,6 +184,11 @@ function renderSummaryCards() {
|
||||
animateCounter('dash-error-rate', rate.toFixed(1) + '%');
|
||||
errorRateEl.classList.toggle('alert', rate > 5);
|
||||
}
|
||||
|
||||
if (document.getElementById('dash-cost-per-run')) {
|
||||
const avgCost = (s.runs_today || 0) > 0 ? (s.cost_today || 0) / s.runs_today : 0;
|
||||
animateCounter('dash-cost-per-run', avgCost ? formatCost(avgCost) : '$0.0000');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTimeseries() {
|
||||
@@ -875,6 +880,10 @@ export async function renderDashboard(routeToken) {
|
||||
<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="vm-strip" id="dash-vm-strip"></div>
|
||||
|
||||
@@ -19,6 +19,15 @@ export function cleanup() {
|
||||
runLiveOps = {};
|
||||
}
|
||||
|
||||
function extractPromptPreview(spans) {
|
||||
for (const sp of spans) {
|
||||
const inner = (sp.payload || {}).payload || {};
|
||||
if (inner.prompt_preview) return inner.prompt_preview;
|
||||
if (inner.message_preview) return inner.message_preview;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSpanPayload(sp) {
|
||||
const outer = sp.payload || {};
|
||||
const inner = outer.payload || {};
|
||||
@@ -361,6 +370,7 @@ export async function renderRun(runID, routeToken) {
|
||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||
: 'ongoing';
|
||||
const runUsage = extractRunUsage(spans);
|
||||
const promptPreview = extractPromptPreview(spans);
|
||||
|
||||
app.innerHTML = `
|
||||
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
||||
@@ -382,6 +392,11 @@ export async function renderRun(runID, routeToken) {
|
||||
</div>
|
||||
</div>
|
||||
${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></div>' : ''}
|
||||
${promptPreview ? `
|
||||
<div class="prompt-preview-section">
|
||||
<div class="prompt-preview-label">Prompt</div>
|
||||
<pre class="prompt-preview-text">${escapeHTML(promptPreview)}</pre>
|
||||
</div>` : ''}
|
||||
<div class="section-title">
|
||||
Spans <span class="count" id="run-detail-span-count">${spans.length}</span>
|
||||
${!r.ended_at ? '<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>Live</span>' : ''}
|
||||
|
||||
@@ -227,27 +227,6 @@ function refreshSessionsTable() {
|
||||
updatePaginationInfo();
|
||||
}
|
||||
|
||||
// Dead code: renderSessionRow is never called but preserved for fidelity
|
||||
function renderSessionRow(s) { // eslint-disable-line no-unused-vars
|
||||
const fw = s.framework || 'unknown';
|
||||
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
|
||||
const active = isSessionActive(s);
|
||||
const dotState = sessionDotState(s);
|
||||
const dotTitle = dotState === 'active'
|
||||
? 'Currently active session'
|
||||
: (active ? 'Open session' : 'Session ended');
|
||||
const errorCell = (s._errorCount || 0) > 0
|
||||
? `<span class="error-count-badge">${s._errorCount}</span>`
|
||||
: '<span style="color:var(--text-dim)">—</span>';
|
||||
return `
|
||||
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)}</td>
|
||||
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
||||
<td>${escapeHTML(s.host || '-')}</td>
|
||||
<td>${s.run_count}</td>
|
||||
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
||||
<td>${errorCell}</td>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateSessionTimers() {
|
||||
const tbody = document.getElementById('sessions-body');
|
||||
|
||||
@@ -2,6 +2,130 @@ import { app, isRouteCurrent } from '../router.js';
|
||||
import { api } from '../api.js';
|
||||
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
|
||||
|
||||
/* global uPlot */
|
||||
|
||||
let usageChart = null;
|
||||
let usageChartMode = 'activity';
|
||||
let usageResizeObserver = null;
|
||||
let _usageSeries = [];
|
||||
|
||||
export function cleanup() {
|
||||
if (usageChart) { usageChart.destroy(); usageChart = null; }
|
||||
if (usageResizeObserver) { usageResizeObserver.disconnect(); usageResizeObserver = null; }
|
||||
_usageSeries = [];
|
||||
}
|
||||
|
||||
function buildChartData(series, mode) {
|
||||
if (!series || series.length === 0) return null;
|
||||
const ts = series.map(b => Math.floor(new Date(b.ts).getTime() / 1000));
|
||||
if (mode === 'tokens') {
|
||||
return [ts, series.map(b => b.input_tokens || 0), series.map(b => b.output_tokens || 0)];
|
||||
}
|
||||
if (mode === 'cost') {
|
||||
return [ts, series.map(b => b.cost || 0)];
|
||||
}
|
||||
return [ts, series.map(b => b.runs || 0), series.map(b => b.tools || 0), series.map(b => b.errors || 0)];
|
||||
}
|
||||
|
||||
function renderChart(series, mode) {
|
||||
const container = document.getElementById('usage-chart');
|
||||
if (!container) return;
|
||||
|
||||
if (usageChart) { usageChart.destroy(); usageChart = null; }
|
||||
container.innerHTML = '';
|
||||
|
||||
const data = buildChartData(series, mode);
|
||||
if (!data) {
|
||||
container.innerHTML = '<p class="empty-state" style="padding:1.5rem">No data for this period</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const width = container.clientWidth || 580;
|
||||
const height = 160;
|
||||
const axisStyle = {
|
||||
stroke: '#4e6070',
|
||||
grid: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
|
||||
ticks: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
|
||||
font: '11px Fira Code',
|
||||
};
|
||||
|
||||
let seriesDef;
|
||||
if (mode === 'tokens') {
|
||||
seriesDef = [
|
||||
{},
|
||||
{ label: 'Input', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
|
||||
{ label: 'Output', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
|
||||
];
|
||||
} else if (mode === 'cost') {
|
||||
seriesDef = [
|
||||
{},
|
||||
{ label: 'Cost', stroke: '#fbbf24', width: 1.75, fill: 'rgba(251,191,36,0.1)', points: { show: false } },
|
||||
];
|
||||
} else {
|
||||
seriesDef = [
|
||||
{},
|
||||
{ label: 'Runs', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
|
||||
{ label: 'Tools', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
|
||||
{ label: 'Errors', stroke: '#f87171', width: 1.5, fill: 'rgba(248,113,113,0.08)', points: { show: false } },
|
||||
];
|
||||
}
|
||||
|
||||
usageChart = new window.uPlot({
|
||||
width, height,
|
||||
cursor: { show: true },
|
||||
scales: { x: { time: true }, y: { auto: true, min: 0 } },
|
||||
axes: [{ ...axisStyle }, { ...axisStyle, size: 52 }],
|
||||
series: seriesDef,
|
||||
}, data, container);
|
||||
|
||||
if (usageResizeObserver) usageResizeObserver.disconnect();
|
||||
usageResizeObserver = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (usageChart) usageChart.setSize({ width: entry.contentRect.width, height });
|
||||
}
|
||||
});
|
||||
usageResizeObserver.observe(container);
|
||||
}
|
||||
|
||||
function renderFrameworkBreakdown(byFw) {
|
||||
const el = document.getElementById('usage-fw-breakdown');
|
||||
if (!el) return;
|
||||
|
||||
const entries = Object.entries(byFw || {}).sort((a, b) => {
|
||||
return (b[1].runs + b[1].tools + b[1].errors) - (a[1].runs + a[1].tools + a[1].errors);
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
el.innerHTML = '<p class="empty-state" style="padding:1rem">No framework data</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors), 1);
|
||||
|
||||
el.innerHTML = entries.map(([name, stats]) => {
|
||||
const total = stats.runs + stats.tools + stats.errors;
|
||||
const pct = (total / maxTotal * 100).toFixed(1);
|
||||
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const active = (stats.active_sessions || 0) > 0;
|
||||
return `
|
||||
<div class="usage-fw-row">
|
||||
<div class="usage-fw-name">
|
||||
<span class="fw-dot ${escapeHTML(cssClass)} ${active ? 'active' : 'ended'}"></span>
|
||||
<span>${escapeHTML(name)}</span>
|
||||
${active ? `<span class="usage-fw-active-badge">${stats.active_sessions} live</span>` : ''}
|
||||
</div>
|
||||
<div class="usage-fw-stats">
|
||||
<span class="usage-fw-stat"><span class="usage-fw-stat-label">runs</span>${stats.runs || 0}</span>
|
||||
<span class="usage-fw-stat"><span class="usage-fw-stat-label">tools</span>${stats.tools || 0}</span>
|
||||
${(stats.errors || 0) > 0 ? `<span class="usage-fw-stat error"><span class="usage-fw-stat-label">err</span>${stats.errors}</span>` : ''}
|
||||
</div>
|
||||
<div class="usage-fw-bar-track">
|
||||
<div class="usage-fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export async function renderUsage(routeToken) {
|
||||
app.innerHTML = `
|
||||
<div class="page-header"><h2>Usage</h2></div>
|
||||
@@ -18,90 +142,149 @@ export async function renderUsage(routeToken) {
|
||||
|
||||
const tools = toolsData.tools || [];
|
||||
const models = modelsData.models || [];
|
||||
const series = tsData.series || [];
|
||||
|
||||
// Aggregate 7d totals from timeseries
|
||||
const totals7d = series.reduce((acc, b) => {
|
||||
acc.runs += b.runs || 0;
|
||||
acc.tools += b.tools || 0;
|
||||
acc.errors += b.errors || 0;
|
||||
acc.tokens += b.tokens || 0;
|
||||
acc.cost += b.cost || 0;
|
||||
return acc;
|
||||
}, { runs: 0, tools: 0, errors: 0, tokens: 0, cost: 0 });
|
||||
|
||||
const s = summary || {};
|
||||
_usageSeries = tsData.series || [];
|
||||
|
||||
const t = _usageSeries.reduce((acc, b) => {
|
||||
acc.runs += b.runs || 0;
|
||||
acc.tools += b.tools || 0;
|
||||
acc.errors += b.errors || 0;
|
||||
acc.tokens += b.tokens || 0;
|
||||
acc.itok += b.input_tokens || 0;
|
||||
acc.otok += b.output_tokens || 0;
|
||||
acc.cost += b.cost || 0;
|
||||
return acc;
|
||||
}, { runs: 0, tools: 0, errors: 0, tokens: 0, itok: 0, otok: 0, cost: 0 });
|
||||
|
||||
const maxModel = models[0]?.count || 1;
|
||||
const maxTool = tools[0]?.count || 1;
|
||||
|
||||
const content = document.getElementById('usage-content');
|
||||
if (!content) return;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="usage-summary-tiles">
|
||||
<div class="meta-tile"><div class="meta-tile-label">Active Sessions</div><div class="meta-tile-value">${s.active_sessions || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Runs Today</div><div class="meta-tile-value">${s.runs_today || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Tool Calls Today</div><div class="meta-tile-value">${s.tool_calls_today || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Errors Today</div><div class="meta-tile-value">${s.errors_today || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Tokens Today</div><div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Cost Today</div><div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div></div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Active Sessions</div>
|
||||
<div class="meta-tile-value">${s.active_sessions || 0}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Runs Today</div>
|
||||
<div class="meta-tile-value">${s.runs_today || 0}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Tool Calls Today</div>
|
||||
<div class="meta-tile-value">${s.tool_calls_today || 0}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Errors Today</div>
|
||||
<div class="meta-tile-value${(s.errors_today || 0) > 0 ? ' has-errors' : ''}">${s.errors_today || 0}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Tokens Today</div>
|
||||
<div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div>
|
||||
${(s.tokens_today || 0) > 0 ? `<div class="meta-tile-sub">${formatTokenCount(s.tokens_today * 0.7 || 0)} in · ${formatTokenCount(s.tokens_today * 0.3 || 0)} out</div>` : ''}
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Cost Today</div>
|
||||
<div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div>
|
||||
${(s.runs_today || 0) > 0 ? `<div class="meta-tile-sub">${formatCost((s.cost_today || 0) / s.runs_today)}/run</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-section-row">
|
||||
<div class="usage-panel">
|
||||
<div class="section-title">7-Day Totals</div>
|
||||
<div class="usage-7d-tiles">
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Runs</span><strong>${totals7d.runs}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tool Calls</span><strong>${totals7d.tools}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Errors</span><strong>${totals7d.errors}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tokens</span><strong>${formatTokenCount(totals7d.tokens)}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Est. Cost</span><strong>${formatCost(totals7d.cost)}</strong></div>
|
||||
<div class="usage-panel usage-chart-panel">
|
||||
<div class="usage-chart-header">
|
||||
<span class="section-title" style="margin:0">7-Day Trend</span>
|
||||
<div class="usage-chart-tabs" id="usage-chart-tabs">
|
||||
<button class="usage-chart-tab active" data-mode="activity">Activity</button>
|
||||
<button class="usage-chart-tab" data-mode="tokens">Tokens</button>
|
||||
<button class="usage-chart-tab" data-mode="cost">Cost</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-chart-totals">
|
||||
<span class="usage-chart-total-pill">
|
||||
<span class="usage-chart-total-label">runs</span>
|
||||
<strong>${t.runs}</strong>
|
||||
</span>
|
||||
<span class="usage-chart-total-pill">
|
||||
<span class="usage-chart-total-label">tools</span>
|
||||
<strong>${t.tools}</strong>
|
||||
</span>
|
||||
<span class="usage-chart-total-pill">
|
||||
<span class="usage-chart-total-label">errors</span>
|
||||
<strong class="${t.errors > 0 ? 'usage-total-errors' : ''}">${t.errors}</strong>
|
||||
</span>
|
||||
<span class="usage-chart-total-pill">
|
||||
<span class="usage-chart-total-label">tokens</span>
|
||||
<strong>${formatTokenCount(t.tokens)}</strong>
|
||||
</span>
|
||||
<span class="usage-chart-total-pill">
|
||||
<span class="usage-chart-total-label">cost</span>
|
||||
<strong>${formatCost(t.cost)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div id="usage-chart"></div>
|
||||
</div>
|
||||
<div class="usage-panel usage-fw-panel">
|
||||
<div class="section-title" style="margin-bottom:0.75rem">
|
||||
Frameworks
|
||||
<span class="count">today</span>
|
||||
</div>
|
||||
<div id="usage-fw-breakdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-section-row">
|
||||
<div class="usage-panel">
|
||||
<div class="section-title">Top Models <span class="count">${models.length}</span></div>
|
||||
${models.length === 0 ? '<p class="empty-state">No model data yet</p>' : `
|
||||
<ul class="stat-list" id="usage-models-list">
|
||||
${(() => {
|
||||
const max = models[0]?.count || 1;
|
||||
return models.map(m => {
|
||||
const pct = (m.count / max * 100).toFixed(1);
|
||||
return `<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(m.name)}</span>
|
||||
<span class="stat-list-count">${m.count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
})()}
|
||||
${models.length === 0 ? '<p class="empty-state" style="padding:1rem">No model data</p>' : `
|
||||
<ul class="stat-list">
|
||||
${models.map(m => {
|
||||
const pct = (m.count / maxModel * 100).toFixed(1);
|
||||
return `<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(m.name)}</span>
|
||||
<span class="stat-list-count">${m.count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('')}
|
||||
</ul>`}
|
||||
</div>
|
||||
|
||||
<div class="usage-panel">
|
||||
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
|
||||
${tools.length === 0 ? '<p class="empty-state">No tool data yet</p>' : `
|
||||
<ul class="stat-list" id="usage-tools-list">
|
||||
${(() => {
|
||||
const max = tools[0]?.count || 1;
|
||||
return tools.map(t => {
|
||||
const pct = (t.count / max * 100).toFixed(1);
|
||||
return `<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(t.name)}</span>
|
||||
<span class="stat-list-count">${t.count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill tool" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
})()}
|
||||
${tools.length === 0 ? '<p class="empty-state" style="padding:1rem">No tool data</p>' : `
|
||||
<ul class="stat-list">
|
||||
${tools.map(t => {
|
||||
const pct = (t.count / maxTool * 100).toFixed(1);
|
||||
return `<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(t.name)}</span>
|
||||
<span class="stat-list-count">${t.count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('')}
|
||||
</ul>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderChart(_usageSeries, usageChartMode);
|
||||
renderFrameworkBreakdown(s.by_framework);
|
||||
|
||||
document.querySelectorAll('.usage-chart-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (usageChartMode === btn.dataset.mode) return;
|
||||
usageChartMode = btn.dataset.mode;
|
||||
document.querySelectorAll('.usage-chart-tab').forEach(b => b.classList.toggle('active', b === btn));
|
||||
renderChart(_usageSeries, usageChartMode);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { renderRun, cleanup as cleanupRunDetail } from './pages/run-
|
||||
import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js';
|
||||
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
|
||||
import { renderSettings } from './pages/settings.js';
|
||||
import { renderUsage } from './pages/usage.js';
|
||||
import { renderUsage, cleanup as cleanupUsage } from './pages/usage.js';
|
||||
|
||||
// Exported so all page modules can write into it without querying the DOM each time
|
||||
export const app = document.getElementById('app');
|
||||
@@ -31,6 +31,7 @@ export function cleanupLiveViews() {
|
||||
cleanupSessionDetail();
|
||||
cleanupRunDetail();
|
||||
cleanupDashboard();
|
||||
cleanupUsage();
|
||||
}
|
||||
|
||||
export function route() {
|
||||
|
||||
+186
-8
@@ -1422,8 +1422,7 @@ tr.expandable:hover .expand-icon::before {
|
||||
|
||||
.stat-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
font-size: 0.8rem;
|
||||
@@ -1914,10 +1913,14 @@ tr.expandable:hover .expand-icon::before {
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.fw-bar-fill.openclaw { background: var(--accent); }
|
||||
.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.unknown { background: var(--text-dim); }
|
||||
.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;
|
||||
@@ -2034,6 +2037,10 @@ tr.expandable:hover .expand-icon::before {
|
||||
.fw-dot.openclaw { background: var(--accent); --fw-glow: var(--accent); }
|
||||
.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); }
|
||||
.fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); }
|
||||
.fw-dot.hermes { background: var(--warning); --fw-glow: var(--warning); }
|
||||
.fw-dot.codex { background: #60a5fa; --fw-glow: #60a5fa; }
|
||||
.fw-dot.gemini { background: #f97316; --fw-glow: #f97316; }
|
||||
.fw-dot.copilot { background: #2dd4bf; --fw-glow: #2dd4bf; }
|
||||
.fw-dot.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); }
|
||||
.fw-dot.ended { opacity: 0.3; }
|
||||
.fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; }
|
||||
@@ -3145,7 +3152,7 @@ tr.clickable.active-session td:first-child {
|
||||
.metric-pill-value {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -3179,7 +3186,7 @@ tr.clickable.active-session td:first-child {
|
||||
.right-panel-tab.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(var(--accent-rgb, 99, 102, 241), 0.08);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.right-panel-body {
|
||||
@@ -3211,7 +3218,7 @@ tr.clickable.active-session td:first-child {
|
||||
.token-stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
@@ -3932,3 +3939,174 @@ tr.clickable:focus-visible {
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ── Usage Page: Chart Panel ─────────────────────────────── */
|
||||
.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);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-chart-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.usage-chart-tab:last-child { border-right: none; }
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
padding: 0.65rem 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.usage-fw-row:last-child { border-bottom: none; }
|
||||
|
||||
.usage-fw-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.usage-fw-active-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--success);
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
|
||||
.usage-fw-stats {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.usage-fw-stat {
|
||||
display: flex;
|
||||
gap: 0.28rem;
|
||||
align-items: baseline;
|
||||
color: var(--text);
|
||||
}
|
||||
.usage-fw-stat.error { color: var(--error); }
|
||||
|
||||
.usage-fw-stat-label {
|
||||
font-size: 0.58rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
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);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--purple);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.875rem 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.prompt-preview-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.prompt-preview-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--code-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── meta-tile: sub-line ─────────────────────────────────── */
|
||||
.meta-tile-value.has-errors { color: var(--error); }
|
||||
|
||||
Reference in New Issue
Block a user