8753c0c9d5
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>
425 lines
17 KiB
JavaScript
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">← 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));
|
|
}
|
|
}
|