184aa5e6cb
Ship the in-progress ES-module refactor of the web-ui (new static/modules/ layout, Usage/Settings pages, uplot-based dashboard) alongside a round of security and UX fixes: - main.go: add CSP + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy middleware on every response; WS CheckOrigin now requires Origin host to match Host (blocks cross-site WebSocket hijacking); upgrade client before dialing upstream so origin check runs first; fatal on unparseable AGENTMON_QUERY_BASE. - app.js: delegated click handler intercepts same-origin <a> clicks for SPA navigation (prev. every nav link caused a full page reload, dropping WS + in-memory state); delegated .copy-btn[data-copy] handler replaces inline onclick=; removed window.navigate / window.copyToClipboard globals and the duplicated handleGlobalSearch. - modules/nav-signal.js: per-route AbortController so in-flight fetches are cancelled when the user navigates away, preventing stale toasts and wasted renders. - modules/api.js: honours the nav signal by default; AbortError is silent. - modules/router.js: resets the nav controller on every route; dropped the fixed 80ms transition delay; breadcrumbs no longer emit inline onclick= (delegated handler picks them up). - modules/utils.js: renderCopyButton emits data-copy=\"...\" instead of nesting a JS string inside an HTML attribute — fixes an XSS where values containing ' broke out via ' decoding. Verified: go build clean; `node --check` clean on all modified modules; manual curl probes confirm security headers present on every response and WS upgrade returns 403 for cross-origin/missing Origin while 101 for same-origin. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
220 lines
8.7 KiB
JavaScript
220 lines
8.7 KiB
JavaScript
import { app, navigate, isRouteCurrent } from '../router.js';
|
|
import { api } from '../api.js';
|
|
import {
|
|
escapeHTML, formatDuration, formatTokenCount, formatCost,
|
|
getEnvelopeCorrelation, getEnvelopeType,
|
|
isCurrentPath, renderCopyButton, statusIcon, extractRunUsage,
|
|
} from '../utils.js';
|
|
import { subscribeWS } from '../ws.js';
|
|
|
|
let sessionDetailUnsubscribe = null;
|
|
let _sessionReloadTimer = null;
|
|
|
|
export function cleanup() {
|
|
if (sessionDetailUnsubscribe) { sessionDetailUnsubscribe(); sessionDetailUnsubscribe = null; }
|
|
clearTimeout(_sessionReloadTimer);
|
|
_sessionReloadTimer = null;
|
|
}
|
|
|
|
function renderSessionRunsRows(runs) {
|
|
if (!runs || runs.length === 0) {
|
|
return '<tr><td colspan="7" class="empty-state">No runs</td></tr>';
|
|
}
|
|
|
|
return runs.map((r, i) => {
|
|
const runDuration = r.ended_at
|
|
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
|
: '-';
|
|
const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-';
|
|
const spans = r.spans || [];
|
|
const spansHTML = spans.length > 0 ? `
|
|
<div class="session-run-spans">
|
|
${spans.map(sp => {
|
|
const body = getSessionSpanSummary(sp);
|
|
return `
|
|
<div class="session-span-pill ${escapeHTML(sp.kind || 'unknown')}">
|
|
<span class="session-span-name">${escapeHTML(sp.name || sp.kind || 'span')}</span>
|
|
<span class="session-span-meta">${escapeHTML(body)}</span>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
` : '<div class="empty-state" style="padding:0.5rem 0">No spans yet</div>';
|
|
|
|
return `
|
|
<tr class="clickable expandable-run ${r.status === 'error' ? 'tr-error' : ''}" data-run="${escapeHTML(r.run_id)}" data-index="${i}">
|
|
<td class="id-cell" title="${escapeHTML(r.run_id)}"><span class="expand-icon"></span>${escapeHTML(r.run_id.substring(0, 12))}...${renderCopyButton(r.run_id)}</td>
|
|
<td>${statusIcon(r.status)}</td>
|
|
<td><span class="model-badge">${modelLabel}</span></td>
|
|
<td>${r.tool_count || 0}</td>
|
|
<td>${r.span_count}</td>
|
|
<td>${escapeHTML(runDuration)}</td>
|
|
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
|
</tr>
|
|
<tr class="span-detail-row" data-index="${i}" style="display:none">
|
|
<td colspan="7">
|
|
<div class="session-run-detail">
|
|
<div class="section-title" style="margin-bottom:0.5rem">Spans <span class="count">${spans.length}</span></div>
|
|
${spansHTML}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function getSessionSpanSummary(sp) {
|
|
const payload = sp.payload || {};
|
|
const innerPayload = payload.payload || {};
|
|
if (sp.kind === 'tool') {
|
|
const result = innerPayload.result_preview || '';
|
|
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
|
|
return result ? `${duration} · ${String(result).slice(0, 80)}` : duration;
|
|
}
|
|
if (sp.kind === 'agent') {
|
|
const usage = innerPayload.usage || {};
|
|
const totalTokens = usage.total_tokens !== undefined ? `${usage.total_tokens} tok` : '';
|
|
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
|
|
return totalTokens ? `${duration} · ${totalTokens}` : duration;
|
|
}
|
|
return sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
|
|
}
|
|
|
|
function bindSessionRunRows() {
|
|
document.querySelectorAll('tr.expandable-run').forEach(row => {
|
|
row.addEventListener('click', event => {
|
|
if (event.metaKey || event.ctrlKey) {
|
|
navigate('/runs/' + row.dataset.run);
|
|
return;
|
|
}
|
|
|
|
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;
|
|
|
|
if (detailRow.style.display === 'none') {
|
|
detailRow.style.display = 'table-row';
|
|
if (icon) icon.style.transform = 'rotate(45deg)';
|
|
} else {
|
|
detailRow.style.display = 'none';
|
|
if (icon) icon.style.transform = '';
|
|
}
|
|
});
|
|
|
|
row.addEventListener('dblclick', () => navigate('/runs/' + row.dataset.run));
|
|
row.setAttribute('tabindex', '0');
|
|
row.setAttribute('role', 'button');
|
|
row.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
row.click();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function handleSessionWS(sessionID, msg) {
|
|
if (msg.type !== 'message') return;
|
|
const correlation = getEnvelopeCorrelation(msg.data);
|
|
if (correlation?.session_id !== sessionID) return;
|
|
const eventType = getEnvelopeType(msg.data);
|
|
if (!['run.start', 'run.end', 'span.start', 'span.end', 'session.end', 'error'].includes(eventType)) return;
|
|
clearTimeout(_sessionReloadTimer);
|
|
_sessionReloadTimer = setTimeout(() => loadSessionData(sessionID), 300);
|
|
}
|
|
|
|
async function loadSessionData(sessionID) {
|
|
if (!isCurrentPath('/sessions/' + sessionID)) return;
|
|
const data = await api('/v1/sessions/' + sessionID);
|
|
const runs = data.runs || [];
|
|
|
|
const tbody = document.getElementById('session-runs-body');
|
|
if (!tbody) return;
|
|
|
|
tbody.innerHTML = renderSessionRunsRows(runs);
|
|
bindSessionRunRows();
|
|
|
|
const countSpan = document.querySelector('.section-title .count');
|
|
if (countSpan) countSpan.textContent = runs.length;
|
|
}
|
|
|
|
export async function renderSession(sessionID, routeToken) {
|
|
const data = await api('/v1/sessions/' + sessionID);
|
|
if (routeToken && !isRouteCurrent(routeToken)) return;
|
|
const s = data.session;
|
|
const runs = data.runs || [];
|
|
const active = !s.ended_at;
|
|
const duration = s.ended_at
|
|
? formatDuration(new Date(s.ended_at) - new Date(s.started_at))
|
|
: 'ongoing';
|
|
|
|
// Aggregate token/cost/tool data from runs' spans
|
|
let sessionTotalTokens = 0, sessionTotalCost = 0, sessionTotalTools = 0;
|
|
runs.forEach(r => {
|
|
const usage = extractRunUsage(r.spans || []);
|
|
if (usage) { sessionTotalTokens += usage.totalTokens; sessionTotalCost += usage.totalCost; }
|
|
sessionTotalTools += (r.tool_count || 0);
|
|
});
|
|
|
|
app.innerHTML = `
|
|
<a href="/sessions" class="back-link">← Back to Sessions</a>
|
|
<div class="page-header">
|
|
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)" title="${escapeHTML(sessionID)}">${escapeHTML(sessionID.substring(0, 16))}...</span>${renderCopyButton(sessionID)}</h2>
|
|
<div class="session-status-line">
|
|
<span class="fw-dot ${escapeHTML((s.framework || 'unknown').replace(/[^a-z0-9-]/g, '-'))} ${active ? 'active' : 'ended'}"></span>
|
|
<span class="session-status-text">${active ? 'Active' : 'Ended'}</span>
|
|
</div>
|
|
<div class="meta-tiles">
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Started</div>
|
|
<div class="meta-tile-value">${escapeHTML(new Date(s.started_at).toLocaleString())}</div>
|
|
</div>
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Framework</div>
|
|
<div class="meta-tile-value">${escapeHTML(s.framework || '-')}</div>
|
|
</div>
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Host</div>
|
|
<div class="meta-tile-value">${escapeHTML(s.host || '-')}</div>
|
|
</div>
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Duration</div>
|
|
<div class="meta-tile-value">${escapeHTML(duration)}</div>
|
|
</div>
|
|
${sessionTotalTokens > 0 ? `<div class="meta-tile"><div class="meta-tile-label">Total Tokens</div><div class="meta-tile-value">${escapeHTML(formatTokenCount(sessionTotalTokens))}</div></div>` : ''}
|
|
${sessionTotalCost > 0 ? `<div class="meta-tile"><div class="meta-tile-label">Total Cost</div><div class="meta-tile-value">${escapeHTML(formatCost(sessionTotalCost))}</div></div>` : ''}
|
|
${sessionTotalTools > 0 ? `<div class="meta-tile"><div class="meta-tile-label">Total Tools</div><div class="meta-tile-value">${sessionTotalTools}</div></div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="section-title">Runs <span class="count">${runs.length}</span></div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Run ID</th>
|
|
<th>Status</th>
|
|
<th>Model</th>
|
|
<th>Tools</th>
|
|
<th>Spans</th>
|
|
<th>Duration</th>
|
|
<th>Started</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="session-runs-body">
|
|
${renderSessionRunsRows(runs)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
bindSessionRunRows();
|
|
|
|
document.querySelector('.back-link').addEventListener('click', e => {
|
|
e.preventDefault();
|
|
navigate('/sessions');
|
|
});
|
|
|
|
sessionDetailUnsubscribe = subscribeWS((msg) => handleSessionWS(sessionID, msg));
|
|
}
|