Files
agentmon/cmd/web-ui/static/modules/pages/session-detail.js
T
William Valentin 184aa5e6cb fix(web-ui): security hardening, SPA nav, and modularization
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 &#39; 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>
2026-04-23 15:36:12 -07:00

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">&larr; 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));
}