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>
This commit is contained in:
William Valentin
2026-04-23 15:36:12 -07:00
parent 41b7165800
commit 184aa5e6cb
20 changed files with 5129 additions and 4216 deletions
+338
View File
@@ -0,0 +1,338 @@
// ── utils.js — pure helpers, no imports ──────────────────
export function escapeHTML(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function tryParseJSON(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
export function animateCounter(elementId, newValue) {
const elem = document.getElementById(elementId);
if (!elem) return;
const oldText = elem.textContent;
const newText = String(newValue);
if (oldText === newText) return;
elem.textContent = newText;
elem.classList.remove('bumped');
void elem.offsetWidth; // force reflow
elem.classList.add('bumped');
}
export function relativeTime(ts) {
if (!ts) return '-';
const now = Date.now();
const then = new Date(ts).getTime();
const diff = now - then;
if (diff < 60000) return 'just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
return Math.floor(diff / 86400000) + 'd ago';
}
export function formatDuration(ms) {
if (ms === undefined || ms === null || ms === '') return '-';
if (ms < 1000) return ms + 'ms';
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
return (ms / 60000).toFixed(1) + 'm';
}
export function formatBytes(bytes) {
if (!bytes) return null;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let unitIndex = 0;
let value = bytes;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return value.toFixed(1) + ' ' + units[unitIndex];
}
export function formatCount(value) {
if (value === undefined || value === null || value === '') return '-';
return String(value);
}
export function formatCost(value) {
if (value === undefined || value === null || value === '') return '-';
const num = Number(value);
if (!Number.isFinite(num)) return String(value);
return '$' + num.toFixed(4);
}
export function formatTokenCount(value) {
if (value === undefined || value === null || value === '') return '-';
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
if (n === 0) return '0';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return String(n);
}
export function formatElapsed(seconds) {
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
}
export function statusIcon(status) {
if (status === 'success') return '<span class="status-badge status-success"><svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px"><polyline points="13.5 4.5 6.5 11.5 2.5 7.5"/></svg>success</span>';
if (status === 'error') return '<span class="status-badge status-error"><svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px"><line x1="13.5" y1="2.5" x2="2.5" y2="13.5"/><line x1="2.5" y1="2.5" x2="13.5" y2="13.5"/></svg>error</span>';
return '<span class="status-badge status-unknown"><span class="status-dot"></span>unknown</span>';
}
export function skeletonRows(rows, cols) {
return Array(rows).fill(0).map(() =>
'<tr>' + Array(cols).fill('<td><div class="skeleton-line"></div></td>').join('') + '</tr>'
).join('');
}
export function dashboardSkeleton() {
return `
<div class="skeleton-summary-row">${Array(4).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:40%"></div><div class="skeleton-line" style="width:20%;height:2rem"></div></div>').join('')}</div>
<div class="skeleton-line" style="width:100%;height:200px;border-radius:var(--radius-lg)"></div>
`;
}
export function sessionsSkeleton() {
return Array(8).fill(0).map((_, i) => {
const widths = [['55%','25%'], ['65%','20%'], ['45%','30%'], ['70%','15%'], ['50%','22%'], ['60%','18%'], ['42%','28%'], ['68%','12%']];
const [w1, w2] = widths[i % widths.length];
return `<tr>
<td><div style="display:flex;align-items:center;gap:0.6rem"><div class="skeleton-circle"></div><div style="flex:1"><div class="skeleton-line" style="width:${w1};margin-bottom:0.3rem"></div><div class="skeleton-line" style="width:${w2}"></div></div></div></td>
<td><div class="skeleton-line" style="width:60%"></div></td>
<td><div class="skeleton-line" style="width:55%"></div></td>
<td><div class="skeleton-line" style="width:30%"></div></td>
<td><div class="skeleton-line" style="width:45%"></div></td>
</tr>`;
}).join('');
}
export function agentsSkeleton() {
return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem">
${Array(4).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:50%"></div><div class="skeleton-line" style="width:70%"></div><div class="skeleton-line" style="width:30%"></div></div>').join('')}
</div>`;
}
export function infrastructureSkeleton() {
return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem">
${Array(6).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:40%"></div><div class="skeleton-line" style="width:60%"></div><div class="skeleton-line" style="width:25%"></div></div>').join('')}
</div>`;
}
// ── Envelope helpers ─────────────────────────────────────
export function extractEnvelope(record) {
if (record && typeof record === 'object' && record.payload && record.payload.event && record.payload.schema) {
return record.payload;
}
return record || {};
}
export function getEnvelopeEvent(record) {
const envelope = extractEnvelope(record);
return envelope.event || envelope.Event || {};
}
export function getEnvelopeType(record) {
return record?.type || getEnvelopeEvent(record).type || '';
}
export function getEnvelopeTS(record) {
return record?.ts || getEnvelopeEvent(record).ts || '';
}
export function getEnvelopeSource(record) {
return getEnvelopeEvent(record).source || {};
}
export function getEnvelopePayload(record) {
const envelope = extractEnvelope(record);
return envelope.payload || envelope.Payload || {};
}
export function getEnvelopeAttributes(record) {
const envelope = extractEnvelope(record);
return envelope.attributes || envelope.Attributes || {};
}
export function getEnvelopeCorrelation(record) {
const envelope = extractEnvelope(record);
return envelope.correlation || envelope.Correlation || {};
}
export function getRecordID(record) {
return record?.event_id || getEnvelopeEvent(record).id || '';
}
export function isCurrentPath(prefix) {
return window.location.pathname.startsWith(prefix);
}
// ── Agent identity helpers ───────────────────────────────
export function normalizeAgentKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-');
}
export function getAgentIdentity(evt) {
const source = getEnvelopeSource(evt);
const correlation = getEnvelopeCorrelation(evt);
const framework = source.framework || evt.source_framework || 'unknown';
const host = source.host || '';
const clientID = source.client_id || '';
const sessionID = correlation.session_id || '';
const name = clientID || host || framework || sessionID || 'unknown';
const key = normalizeAgentKey(clientID || host || sessionID || framework || 'unknown');
return { key, name, framework, host, clientID, sessionID };
}
// ── VM/event display helpers ─────────────────────────────
export function getVMName(evt) {
return getAgentIdentity(evt).name || 'unknown';
}
export function getVMClassName(vmName) {
const normalized = String(vmName || 'unknown').toLowerCase();
return ['zap', 'orb', 'sun'].includes(normalized) ? normalized : 'unknown';
}
export function getEventIcon(eventType) {
switch (eventType) {
case 'run.start': return '<div class="event-icon message-in">&#8595;</div>';
case 'run.end': return '<div class="event-icon message-out">&#8593;</div>';
case 'span.start':
case 'span.end': return '<div class="event-icon tool">&#9881;</div>';
case 'error': return '<div class="event-icon error">!</div>';
case 'session.start':
case 'session.end': return '<div class="event-icon session">&#9675;</div>';
default: return '<div class="event-icon internal">&#183;</div>';
}
}
export function getEventLabel(eventType) {
const labels = {
'session.start': 'Session Started',
'session.end': 'Session Ended',
'run.start': 'Message Received',
'run.end': 'Response Sent',
'span.start': 'Span Started',
'span.end': 'Span Completed',
'error': 'Error',
'metric.snapshot': 'Metric',
};
return labels[eventType] || eventType;
}
export function getEventBody(evt) {
const eventType = getEnvelopeType(evt);
const payload = getEnvelopePayload(evt);
const attrs = getEnvelopeAttributes(evt);
const correlation = getEnvelopeCorrelation(evt);
if (eventType === 'span.start' || eventType === 'span.end') {
const name = attrs.name || attrs.span_kind || 'unknown span';
const duration = payload.duration_ms !== undefined && payload.duration_ms !== null
? ` <span class="timeline-duration">${escapeHTML(formatDuration(payload.duration_ms))}</span>`
: '';
const detailClass = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? ' subagent-name' : ' tool-name';
const prefix = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? 'subagent ' : '';
return `<div class="timeline-event-body${detailClass}">${escapeHTML(prefix + name)}${duration}</div>`;
}
if (eventType === 'run.start') {
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
if (!preview) return '';
const trimmed = preview.length > 140 ? preview.slice(0, 140) + '...' : preview;
return `<div class="timeline-event-body message-preview">"${escapeHTML(trimmed)}"</div>`;
}
if (eventType === 'run.end') {
return `<div class="timeline-event-body">${statusIcon(payload.status || 'unknown')}</div>`;
}
if (eventType === 'error') {
const errPayload = payload.error || {};
const errType = errPayload.type || 'error';
const message = errPayload.message || payload.message || 'unknown';
return `<div class="timeline-event-body error-message">${escapeHTML(errType + ': ' + message)}</div>`;
}
if (eventType === 'session.start' || eventType === 'session.end') {
return correlation.session_id
? `<div class="timeline-event-body">session ${escapeHTML(correlation.session_id)}</div>`
: '';
}
return '';
}
export function getEventDetails(evt) {
const details = {};
const correlation = getEnvelopeCorrelation(evt);
const attributes = getEnvelopeAttributes(evt);
const payload = getEnvelopePayload(evt);
if (Object.keys(correlation).length > 0) details.correlation = correlation;
if (Object.keys(attributes).length > 0) details.attributes = attributes;
if (Object.keys(payload).length > 0) details.payload = payload;
if (Object.keys(details).length === 0) return '';
return JSON.stringify(details, null, 2);
}
export function isAgentTimelineEvent(evt) {
const eventType = getEnvelopeType(evt);
return [
'session.start', 'session.end',
'run.start', 'run.end',
'span.start', 'span.end',
'error',
].includes(eventType);
}
export function isDashboardFeedEvent(evt) {
const eventType = getEnvelopeType(evt);
return isAgentTimelineEvent(evt) || eventType === 'metric.snapshot';
}
// ── Run usage extraction ─────────────────────────────────
export function extractRunUsage(spans) {
let totalTokens = 0, inputTokens = 0, outputTokens = 0, totalCost = 0;
let found = false;
(spans || []).forEach(sp => {
const inner = (sp.payload || {}).payload || {};
const checkUsage = (u) => {
if (!u) return;
if (u.total_tokens != null) { totalTokens = Math.max(totalTokens, Number(u.total_tokens) || 0); found = true; }
if (u.input_tokens != null) inputTokens = Math.max(inputTokens, Number(u.input_tokens) || 0);
if (u.output_tokens != null) outputTokens = Math.max(outputTokens, Number(u.output_tokens) || 0);
if (u.total_cost != null) totalCost = Math.max(totalCost, Number(u.total_cost) || 0);
};
checkUsage(inner.usage);
if (inner.metrics) checkUsage(inner.metrics.usage);
});
return found ? { totalTokens, inputTokens, outputTokens, totalCost } : null;
}
// ── Copy button ──────────────────────────────────────────
export function renderCopyButton(text) {
// data-copy is decoded in a single HTML-attribute context by the parser,
// so escapeHTML is sufficient. A delegated click handler in app.js picks
// these up and calls copyToClipboard — no inline onclick needed.
return `<button class="copy-btn" type="button" title="Copy to clipboard" data-copy="${escapeHTML(text)}">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="9" height="9" rx="1.5"/><path d="M10.5 1.5H2.5A1.5 1.5 0 0 0 1 3v8"/></svg>
</button>`;
}