From 8753c0c9d5c1981419577a8c5aa60372452cb873 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 21 May 2026 16:49:05 -0700 Subject: [PATCH] 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 --- cmd/web-ui/static/modules/pages/dashboard.js | 9 + cmd/web-ui/static/modules/pages/run-detail.js | 15 + cmd/web-ui/static/modules/pages/sessions.js | 21 -- cmd/web-ui/static/modules/pages/usage.js | 303 ++++++++++++++---- cmd/web-ui/static/modules/router.js | 3 +- cmd/web-ui/static/style.css | 194 ++++++++++- 6 files changed, 455 insertions(+), 90 deletions(-) diff --git a/cmd/web-ui/static/modules/pages/dashboard.js b/cmd/web-ui/static/modules/pages/dashboard.js index 925c029..591c0fe 100644 --- a/cmd/web-ui/static/modules/pages/dashboard.js +++ b/cmd/web-ui/static/modules/pages/dashboard.js @@ -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) { Error rate - +
+ Cost / run + - +
Infrastructure
diff --git a/cmd/web-ui/static/modules/pages/run-detail.js b/cmd/web-ui/static/modules/pages/run-detail.js index 98a4e42..23b9fdb 100644 --- a/cmd/web-ui/static/modules/pages/run-detail.js +++ b/cmd/web-ui/static/modules/pages/run-detail.js @@ -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 = ` ← Back to Session @@ -382,6 +392,11 @@ export async function renderRun(runID, routeToken) { ${!r.ended_at ? '
' : ''} + ${promptPreview ? ` +
+
Prompt
+
${escapeHTML(promptPreview)}
+
` : ''}
Spans ${spans.length} ${!r.ended_at ? 'Live' : ''} diff --git a/cmd/web-ui/static/modules/pages/sessions.js b/cmd/web-ui/static/modules/pages/sessions.js index a95ff56..97d227f 100644 --- a/cmd/web-ui/static/modules/pages/sessions.js +++ b/cmd/web-ui/static/modules/pages/sessions.js @@ -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 - ? `${s._errorCount}` - : ''; - return ` - ${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)} - ${escapeHTML(fw)} - ${escapeHTML(s.host || '-')} - ${s.run_count} - ${escapeHTML(relativeTime(s.started_at))} - ${errorCell} - `; -} function updateSessionTimers() { const tbody = document.getElementById('sessions-body'); diff --git a/cmd/web-ui/static/modules/pages/usage.js b/cmd/web-ui/static/modules/pages/usage.js index 63ed7ce..ceb6bee 100644 --- a/cmd/web-ui/static/modules/pages/usage.js +++ b/cmd/web-ui/static/modules/pages/usage.js @@ -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 = '

No data for this period

'; + 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 = '

No framework data

'; + 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 ` +
+
+ + ${escapeHTML(name)} + ${active ? `${stats.active_sessions} live` : ''} +
+
+ runs${stats.runs || 0} + tools${stats.tools || 0} + ${(stats.errors || 0) > 0 ? `err${stats.errors}` : ''} +
+
+
+
+
`; + }).join(''); +} + export async function renderUsage(routeToken) { app.innerHTML = ` @@ -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 = `
-
Active Sessions
${s.active_sessions || 0}
-
Runs Today
${s.runs_today || 0}
-
Tool Calls Today
${s.tool_calls_today || 0}
-
Errors Today
${s.errors_today || 0}
-
Tokens Today
${formatTokenCount(s.tokens_today || 0)}
-
Cost Today
${formatCost(s.cost_today || 0)}
+
+
Active Sessions
+
${s.active_sessions || 0}
+
+
+
Runs Today
+
${s.runs_today || 0}
+
+
+
Tool Calls Today
+
${s.tool_calls_today || 0}
+
+
+
Errors Today
+
${s.errors_today || 0}
+
+
+
Tokens Today
+
${formatTokenCount(s.tokens_today || 0)}
+ ${(s.tokens_today || 0) > 0 ? `
${formatTokenCount(s.tokens_today * 0.7 || 0)} in · ${formatTokenCount(s.tokens_today * 0.3 || 0)} out
` : ''} +
+
+
Cost Today
+
${formatCost(s.cost_today || 0)}
+ ${(s.runs_today || 0) > 0 ? `
${formatCost((s.cost_today || 0) / s.runs_today)}/run
` : ''} +
-
-
7-Day Totals
-
-
Runs${totals7d.runs}
-
Tool Calls${totals7d.tools}
-
Errors${totals7d.errors}
-
Tokens${formatTokenCount(totals7d.tokens)}
-
Est. Cost${formatCost(totals7d.cost)}
+
+
+ 7-Day Trend +
+ + + +
+
+ + runs + ${t.runs} + + + tools + ${t.tools} + + + errors + ${t.errors} + + + tokens + ${formatTokenCount(t.tokens)} + + + cost + ${formatCost(t.cost)} + +
+
+
+
+
+ Frameworks + today +
+
Top Models ${models.length}
- ${models.length === 0 ? '

No model data yet

' : ` -
    - ${(() => { - const max = models[0]?.count || 1; - return models.map(m => { - const pct = (m.count / max * 100).toFixed(1); - return `
  • -
    - ${escapeHTML(m.name)} - ${m.count} -
    -
    -
    -
    -
  • `; - }).join(''); - })()} + ${models.length === 0 ? '

    No model data

    ' : ` +
      + ${models.map(m => { + const pct = (m.count / maxModel * 100).toFixed(1); + return `
    • +
      + ${escapeHTML(m.name)} + ${m.count} +
      +
      +
      +
      +
    • `; + }).join('')}
    `}
Top Tools ${tools.length}
- ${tools.length === 0 ? '

No tool data yet

' : ` -
    - ${(() => { - const max = tools[0]?.count || 1; - return tools.map(t => { - const pct = (t.count / max * 100).toFixed(1); - return `
  • -
    - ${escapeHTML(t.name)} - ${t.count} -
    -
    -
    -
    -
  • `; - }).join(''); - })()} + ${tools.length === 0 ? '

    No tool data

    ' : ` +
      + ${tools.map(t => { + const pct = (t.count / maxTool * 100).toFixed(1); + return `
    • +
      + ${escapeHTML(t.name)} + ${t.count} +
      +
      +
      +
      +
    • `; + }).join('')}
    `}
`; + + 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); + }); + }); } diff --git a/cmd/web-ui/static/modules/router.js b/cmd/web-ui/static/modules/router.js index 60668c7..e54288d 100644 --- a/cmd/web-ui/static/modules/router.js +++ b/cmd/web-ui/static/modules/router.js @@ -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() { diff --git a/cmd/web-ui/static/style.css b/cmd/web-ui/static/style.css index ff26261..41f08a1 100644 --- a/cmd/web-ui/static/style.css +++ b/cmd/web-ui/static/style.css @@ -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); }