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
-
+
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 = `
-
-
7-Day Totals
-
-
Runs${totals7d.runs}
-
Tool Calls${totals7d.tools}
-
Errors${totals7d.errors}
-
Tokens${formatTokenCount(totals7d.tokens)}
-
Est. Cost${formatCost(totals7d.cost)}
+
+
+
+
+ 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 `-
-
-
-
`;
- }).join('');
- })()}
+ ${models.length === 0 ? 'No model data
' : `
+
+ ${models.map(m => {
+ const pct = (m.count / maxModel * 100).toFixed(1);
+ return `-
+
+
+
`;
+ }).join('')}
`}
Top Tools ${tools.length}
- ${tools.length === 0 ? '
No tool data yet
' : `
-
`;
+
+ 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); }