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 ' 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:
@@ -0,0 +1,338 @@
|
||||
// ── utils.js — pure helpers, no imports ──────────────────
|
||||
|
||||
export function escapeHTML(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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">↓</div>';
|
||||
case 'run.end': return '<div class="event-icon message-out">↑</div>';
|
||||
case 'span.start':
|
||||
case 'span.end': return '<div class="event-icon tool">⚙</div>';
|
||||
case 'error': return '<div class="event-icon error">!</div>';
|
||||
case 'session.start':
|
||||
case 'session.end': return '<div class="event-icon session">○</div>';
|
||||
default: return '<div class="event-icon internal">·</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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user