Files
William Valentin 8753c0c9d5 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>
2026-05-21 16:49:05 -07:00

425 lines
17 KiB
JavaScript

import { app, navigate, isRouteCurrent } from '../router.js';
import { api } from '../api.js';
import {
escapeHTML, formatDuration, formatTokenCount, formatCost, formatElapsed,
getEnvelopeCorrelation, getEnvelopeType, getEnvelopeAttributes, getEnvelopePayload,
isCurrentPath, renderCopyButton, statusIcon, skeletonRows, extractRunUsage,
} from '../utils.js';
import { subscribeWS } from '../ws.js';
let runDetailUnsubscribe = null;
let runLiveOps = {}; // spanID → { name, kind, startedAt, promptPreview, inputPreview }
let _runReloadTimer = null;
let runSpansViewMode = 'table';
export function cleanup() {
if (runDetailUnsubscribe) { runDetailUnsubscribe(); runDetailUnsubscribe = null; }
clearTimeout(_runReloadTimer);
_runReloadTimer = null;
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 || {};
const parts = [];
if (sp.kind === 'tool') {
if (inner.input !== undefined) {
const inputStr = typeof inner.input === 'object'
? JSON.stringify(inner.input, null, 2)
: String(inner.input);
parts.push(`<div class="span-kv"><span class="span-kv-key">Input</span><pre class="span-kv-val span-kv-raw">${escapeHTML(inputStr)}</pre></div>`);
}
if (inner.result_preview !== undefined) {
parts.push(`<div class="span-kv"><span class="span-kv-key">Result</span><pre class="span-kv-val span-kv-raw">${escapeHTML(String(inner.result_preview))}</pre></div>`);
}
} else if (sp.kind === 'agent') {
if (inner.prompt_preview) {
parts.push(`<div class="span-kv"><span class="span-kv-key">Prompt</span><pre class="span-kv-val span-kv-raw">${escapeHTML(String(inner.prompt_preview))}</pre></div>`);
}
if (inner.usage) {
const u = inner.usage;
const tokens = [
u.total_tokens != null ? `${u.total_tokens} total` : null,
u.input_tokens != null ? `${u.input_tokens} in` : null,
u.output_tokens != null ? `${u.output_tokens} out` : null,
].filter(Boolean).join(' · ');
if (tokens) parts.push(`<div class="span-kv"><span class="span-kv-key">Tokens</span><span class="span-kv-val">${escapeHTML(tokens)}</span></div>`);
if (u.total_cost != null) {
parts.push(`<div class="span-kv"><span class="span-kv-key">Cost</span><span class="span-kv-val">${escapeHTML(formatCost(u.total_cost))}</span></div>`);
}
}
if (inner.model) {
parts.push(`<div class="span-kv"><span class="span-kv-key">Model</span><span class="span-kv-val">${escapeHTML(String(inner.model))}</span></div>`);
}
} else {
const raw = Object.keys(inner).length > 0 ? inner : (Object.keys(outer).length > 0 ? outer : null);
if (raw) {
parts.push(`<pre class="span-kv-raw">${escapeHTML(JSON.stringify(raw, null, 2))}</pre>`);
}
}
if (sp.duration_ms != null) {
parts.push(`<div class="span-kv"><span class="span-kv-key">Duration</span><span class="span-kv-val">${escapeHTML(formatDuration(sp.duration_ms))}</span></div>`);
}
return parts.length > 0
? parts.join('')
: '<span style="font-size:0.75rem;color:var(--text-dim)">No payload data</span>';
}
function renderTimescale(totalMS) {
const ticks = 5;
return Array.from({ length: ticks + 1 }, (_, i) => {
const pct = (i / ticks * 100).toFixed(0);
return `<span style="left:${pct}%">${escapeHTML(formatDuration(totalMS * i / ticks))}</span>`;
}).join('');
}
function renderSpanWaterfall(spans, runStartedAt, runDurationMS) {
if (!spans || spans.length === 0) return '<p class="empty-state">No spans</p>';
const runStart = new Date(runStartedAt).getTime();
const totalMS = runDurationMS || Math.max(...spans.map(sp => {
const s = new Date(sp.started_at || runStartedAt).getTime();
return (s - runStart) + (sp.duration_ms || 0);
}), 1);
return `
<div class="waterfall">
<div class="waterfall-header">
<div class="waterfall-name-col">Span</div>
<div class="waterfall-bar-col">
<div class="waterfall-timescale">${renderTimescale(totalMS)}</div>
</div>
</div>
${spans.map(sp => {
const spStart = sp.started_at ? new Date(sp.started_at).getTime() - runStart : 0;
const spDur = sp.duration_ms || 0;
const leftPct = Math.max(0, (spStart / totalMS * 100)).toFixed(2);
const widthPct = Math.max(0.5, (spDur / totalMS * 100)).toFixed(2);
const kindClass = sp.kind || 'unknown';
const statusClass = sp.status === 'error' ? ' wf-error' : sp.status === 'success' ? ' wf-success' : '';
return `
<div class="waterfall-row">
<div class="waterfall-name-col">
<span class="span-kind-badge ${escapeHTML(kindClass)}">${escapeHTML(sp.kind || '?')}</span>
<span class="waterfall-name" title="${escapeHTML(sp.name || '')}">${escapeHTML((sp.name || '(unnamed)').slice(0, 40))}</span>
</div>
<div class="waterfall-bar-col">
<div class="waterfall-bar-track">
<div class="waterfall-bar${statusClass}" style="left:${leftPct}%;width:${widthPct}%" title="${escapeHTML(formatDuration(spDur))}">
<span class="waterfall-bar-label">${spDur > totalMS * 0.05 ? escapeHTML(formatDuration(spDur)) : ''}</span>
</div>
</div>
</div>
</div>`;
}).join('')}
</div>`;
}
function renderRunSpansRows(spans) {
if (!spans || spans.length === 0) {
return '<tr><td colspan="4" class="empty-state">No spans</td></tr>';
}
return spans.map((sp, i) => {
const kindClass = sp.kind || 'unknown';
return `
<tr class="expandable run-span-row ${sp.status === 'error' ? 'tr-error' : ''}" data-index="${i}">
<td>
<span class="expand-icon"></span>
<span class="span-kind-badge ${escapeHTML(kindClass)}">${escapeHTML(sp.kind || '?')}</span>
${escapeHTML(sp.name || '(unnamed)')}
</td>
<td>${escapeHTML(sp.kind || '-')}</td>
<td>${statusIcon(sp.status)}</td>
<td>${escapeHTML(formatDuration(sp.duration_ms))}</td>
</tr>
<tr class="span-detail-row" data-index="${i}" style="display:none">
<td colspan="4">
<div class="span-details-structured">${renderSpanPayload(sp)}</div>
</td>
</tr>`;
}).join('');
}
function renderRunSpansTable(spans) {
return `
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Kind</th>
<th>Status</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="spans-body">
${renderRunSpansRows(spans)}
</tbody>
</table>
</div>`;
}
function captureOpenSpanIndices() {
const openIndices = new Set();
document.querySelectorAll('tr.span-detail-row').forEach(row => {
if (row.style.display !== 'none') openIndices.add(row.dataset.index);
});
return openIndices;
}
function restoreOpenSpanIndices(openIndices) {
if (!openIndices || openIndices.size === 0) return;
document.querySelectorAll('tr.span-detail-row').forEach(row => {
if (!openIndices.has(row.dataset.index)) return;
row.style.display = 'table-row';
const hdr = document.querySelector(`tr.run-span-row[data-index="${row.dataset.index}"]`);
const icon = hdr?.querySelector('.expand-icon');
if (icon) icon.style.transform = 'rotate(45deg)';
});
}
function updateSpanViewButtons() {
document.getElementById('spans-view-table')?.classList.toggle('active', runSpansViewMode === 'table');
document.getElementById('spans-view-waterfall')?.classList.toggle('active', runSpansViewMode === 'waterfall');
}
function renderSpansView(spans, run, openIndices) {
const container = document.getElementById('spans-container');
if (!container) return;
if (runSpansViewMode === 'waterfall') {
container.innerHTML = renderSpanWaterfall(
spans,
run.started_at,
run.ended_at ? new Date(run.ended_at) - new Date(run.started_at) : null,
);
} else {
container.innerHTML = renderRunSpansTable(spans);
bindRunSpanRows();
restoreOpenSpanIndices(openIndices);
}
updateSpanViewButtons();
}
function bindSpanViewToggles(spans, run) {
const tableBtn = document.getElementById('spans-view-table');
const waterfallBtn = document.getElementById('spans-view-waterfall');
if (tableBtn) {
tableBtn.onclick = () => {
runSpansViewMode = 'table';
renderSpansView(spans, run, captureOpenSpanIndices());
};
}
if (waterfallBtn) {
waterfallBtn.onclick = () => {
runSpansViewMode = 'waterfall';
renderSpansView(spans, run, captureOpenSpanIndices());
};
}
}
function bindRunSpanRows() {
document.querySelectorAll('tr.run-span-row').forEach(row => {
row.addEventListener('click', () => {
const idx = row.dataset.index;
const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`);
const icon = row.querySelector('.expand-icon');
if (!detailRow) return;
const isOpen = detailRow.style.display !== 'none';
detailRow.style.display = isOpen ? 'none' : 'table-row';
if (icon) icon.style.transform = isOpen ? '' : 'rotate(45deg)';
});
row.setAttribute('tabindex', '0');
row.setAttribute('role', 'button');
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
row.click();
}
});
});
}
function renderRunLiveOps() {
const el = document.getElementById('run-live-ops');
if (!el) return;
const ops = Object.values(runLiveOps);
if (ops.length === 0) {
el.innerHTML = '';
return;
}
el.innerHTML = `<div class="run-live-ops-inner">${ops.map(op => {
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
const isSubagent = op.kind === 'agent' || op.subType === 'subagent';
const icon = isSubagent ? '◎' : op.kind === 'run' ? '◌' : '▸';
const label = isSubagent ? 'subagent' : op.kind === 'run' ? 'thinking' : 'tool';
const preview = op.promptPreview || op.inputPreview || '';
return `
<div class="run-live-op-pill ${label}">
<span class="run-live-op-spin">${icon}</span>
<span class="run-live-op-name">${escapeHTML(op.name)}</span>
${preview ? `<span class="run-live-op-preview">${escapeHTML(preview.length > 60 ? preview.slice(0, 60) + '…' : preview)}</span>` : ''}
<span class="run-live-op-time active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
</div>`;
}).join('')}</div>`;
}
function handleRunWS(runID, msg) {
if (msg.type !== 'message') return;
const correlation = getEnvelopeCorrelation(msg.data);
if (correlation?.run_id !== runID) return;
// Track live ops from WS without full reload
const eventType = getEnvelopeType(msg.data);
const attrs = getEnvelopeAttributes(msg.data);
const payload = getEnvelopePayload(msg.data);
const spanID = correlation.span_id;
if (eventType === 'span.start' && spanID) {
runLiveOps[spanID] = {
name: attrs.name || attrs.span_kind || 'span',
kind: attrs.span_kind || '',
subType: attrs.type || '',
startedAt: Date.now(),
promptPreview: payload.prompt_preview || '',
inputPreview: payload.input ? (typeof payload.input === 'string' ? payload.input.slice(0, 100) : '') : '',
};
renderRunLiveOps();
}
if (eventType === 'span.end' && spanID) {
delete runLiveOps[spanID];
renderRunLiveOps();
}
if (eventType === 'run.start') {
runLiveOps['__run__'] = {
name: 'Thinking…',
kind: 'run',
startedAt: Date.now(),
promptPreview: payload.prompt_preview || payload.message_preview || payload.message || '',
inputPreview: '',
};
renderRunLiveOps();
}
if (eventType === 'run.end') {
delete runLiveOps['__run__'];
runLiveOps = {};
renderRunLiveOps();
}
clearTimeout(_runReloadTimer);
_runReloadTimer = setTimeout(() => loadRunDetailData(runID), 500);
}
async function loadRunDetailData(runID) {
if (!isCurrentPath('/runs/' + runID)) return;
try {
const data = await api('/v1/runs/' + runID);
const spans = data.spans || [];
const r = data.run;
const openIndices = captureOpenSpanIndices();
renderSpansView(spans, r, openIndices);
bindSpanViewToggles(spans, r);
const countEl = document.getElementById('run-detail-span-count');
if (countEl) countEl.textContent = spans.length;
if (r.ended_at) {
const durEl = document.getElementById('run-detail-duration');
if (durEl) durEl.textContent = formatDuration(new Date(r.ended_at) - new Date(r.started_at));
if (runDetailUnsubscribe) { runDetailUnsubscribe(); runDetailUnsubscribe = null; }
const liveSpan = document.querySelector('.section-title .live-indicator');
if (liveSpan) liveSpan.remove();
}
} catch (e) {
console.error('Failed to reload run detail:', e);
}
}
export async function renderRun(runID, routeToken) {
app.innerHTML = '<div style="padding:2rem"><div class="skeleton-line" style="width:40%;height:1.5rem;margin-bottom:1rem"></div><div class="skeleton-line" style="width:60%;margin-bottom:2rem"></div>' + '<div class="table-container"><table><thead><tr><th>Name</th><th>Kind</th><th>Status</th><th>Duration</th></tr></thead><tbody>' + skeletonRows(5, 4) + '</tbody></table></div></div>';
runLiveOps = {};
runSpansViewMode = 'table';
let data;
try {
data = await api('/v1/runs/' + runID);
} catch (e) {
if (routeToken && !isRouteCurrent(routeToken)) return;
app.innerHTML = `<p class="empty-state">Error loading run: ${escapeHTML(e.message)}</p>`;
return;
}
if (routeToken && !isRouteCurrent(routeToken)) return;
const r = data.run;
const spans = data.spans || [];
const duration = r.ended_at
? 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">&larr; Back to Session</a>
<div class="page-header">
<h2>Run <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)" title="${escapeHTML(runID)}">${escapeHTML(runID.substring(0, 16))}…</span>${renderCopyButton(runID)} ${statusIcon(r.status)}</h2>
<div class="meta-tiles">
<div class="meta-tile">
<div class="meta-tile-label">Started</div>
<div class="meta-tile-value">${escapeHTML(new Date(r.started_at).toLocaleString())}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Duration</div>
<div class="meta-tile-value" id="run-detail-duration">${escapeHTML(duration)}</div>
</div>
${r.model ? `<div class="meta-tile"><div class="meta-tile-label">Model</div><div class="meta-tile-value" style="font-size:0.78rem">${escapeHTML(r.model.replace(/^claude-/, ''))}</div></div>` : ''}
${r.tool_count ? `<div class="meta-tile"><div class="meta-tile-label">Tool Calls</div><div class="meta-tile-value">${r.tool_count}</div></div>` : ''}
${runUsage ? `<div class="meta-tile"><div class="meta-tile-label">Tokens</div><div class="meta-tile-value">${escapeHTML(formatTokenCount(runUsage.totalTokens))}</div>${(runUsage.inputTokens || runUsage.outputTokens) ? `<div class="meta-tile-sub">${escapeHTML(formatTokenCount(runUsage.inputTokens))} in · ${escapeHTML(formatTokenCount(runUsage.outputTokens))} out</div>` : ''}</div>` : ''}
${runUsage ? `<div class="meta-tile"><div class="meta-tile-label">Cost</div><div class="meta-tile-value">${escapeHTML(formatCost(runUsage.totalCost))}</div></div>` : ''}
</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>' : ''}
<div class="view-toggle" style="margin-left:auto">
<button class="view-toggle-btn active" id="spans-view-table" type="button">Table</button>
<button class="view-toggle-btn" id="spans-view-waterfall" type="button">Waterfall</button>
</div>
</div>
<div id="spans-container">
${renderRunSpansTable(spans)}
</div>
`;
bindRunSpanRows();
bindSpanViewToggles(spans, r);
document.querySelector('.back-link').addEventListener('click', e => {
e.preventDefault();
navigate('/sessions/' + r.session_id);
});
if (!r.ended_at) {
runDetailUnsubscribe = subscribeWS((msg) => handleRunWS(runID, msg));
}
}