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
+939
View File
@@ -0,0 +1,939 @@
// ── agents.js — Agents page ───────────────────────────────
import {
escapeHTML,
formatDuration,
formatCount,
formatCost,
formatTokenCount,
formatElapsed,
getEnvelopeType,
getEnvelopePayload,
getEnvelopeAttributes,
getEnvelopeCorrelation,
getEnvelopeTS,
getRecordID,
getEventIcon,
getEventLabel,
getEventBody,
getEventDetails,
isAgentTimelineEvent,
isCurrentPath,
getAgentIdentity,
normalizeAgentKey,
agentsSkeleton,
} from '../utils.js';
import { subscribeWS } from '../ws.js';
import {
agentsState,
resetAgentsState,
mergeOpenClawEvents,
getVMStatus,
isOpenClawVM,
isAgentOnline,
openclawState,
} from '../state.js';
import { app, navigate, renderBreadcrumbs, isRouteCurrent } from '../router.js';
import { api } from '../api.js';
// ── Module-level state ───────────────────────────────────
let agentsUnsubscribe = null;
let _agentsRenderTimer = null;
// ── Private helpers ──────────────────────────────────────
function ensureAgentBucket(evt) {
const identity = getAgentIdentity(evt);
if (!identity.key) return null;
if (!agentsState.agents[identity.key]) {
agentsState.agents[identity.key] = {
key: identity.key,
name: identity.name,
framework: identity.framework,
host: identity.host,
clientID: identity.clientID,
sessions: {},
operations: {},
events: [],
eventIDs: new Set(),
lastSeenAt: 0,
liveLoaded: false,
liveLoading: false,
};
}
const agent = agentsState.agents[identity.key];
agent.name = identity.name || agent.name || identity.key;
agent.framework = identity.framework || agent.framework;
agent.host = identity.host || agent.host;
agent.clientID = identity.clientID || agent.clientID;
return agent;
}
function getSortedAgentKeys() {
return Object.keys(agentsState.agents).sort((a, b) => {
const left = agentsState.agents[a];
const right = agentsState.agents[b];
const leftOnline = isAgentOnline(left);
const rightOnline = isAgentOnline(right);
if (leftOnline !== rightOnline) return leftOnline ? -1 : 1;
return (left.name || left.key).localeCompare(right.name || right.key);
});
}
function ensureSelectedAgentKey() {
const keys = getSortedAgentKeys();
if (keys.length === 0) {
agentsState.selectedAgentKey = '';
return '';
}
if (!agentsState.selectedAgentKey || !agentsState.agents[agentsState.selectedAgentKey]) {
agentsState.selectedAgentKey = keys[0];
}
return agentsState.selectedAgentKey;
}
function setAgentsViewMode(mode) {
agentsState.viewMode = mode === 'live' ? 'live' : 'overview';
renderAgentsContent();
if (agentsState.viewMode === 'live') {
void loadSelectedAgentLiveData();
}
}
function getAgentBucket(evt) {
return ensureAgentBucket(evt);
}
function processAgentEvent(evt) {
const agent = getAgentBucket(evt);
if (!agent) return;
const eventType = getEnvelopeType(evt);
const correlation = getEnvelopeCorrelation(evt);
const attrs = getEnvelopeAttributes(evt);
const ts = new Date(getEnvelopeTS(evt)).getTime();
agent.lastSeenAt = Number.isFinite(ts) ? ts : Date.now();
if (eventType === 'session.start' && correlation.session_id) {
agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) };
}
if (eventType === 'session.end' && correlation.session_id) {
delete agent.sessions[correlation.session_id];
}
if (eventType === 'span.start' && correlation.span_id) {
const payload = getEnvelopePayload(evt);
agent.operations['s:' + correlation.span_id] = {
type: 'span',
name: attrs.name || attrs.span_kind || 'unknown',
kind: attrs.span_kind || '',
subType: attrs.type || '',
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
promptPreview: payload.prompt_preview || '',
inputPreview: payload.input ? (typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input)) : '',
spanID: correlation.span_id,
runID: correlation.run_id || '',
};
}
if (eventType === 'span.end' && correlation.span_id) {
const op = agent.operations['s:' + correlation.span_id];
if (op) {
const payload = getEnvelopePayload(evt);
op.resultPreview = payload.result_preview || '';
op.status = payload.status || '';
op.durationMS = payload.duration_ms || 0;
op.endedAt = new Date(getEnvelopeTS(evt)).getTime() || Date.now();
op.usage = payload.usage || null;
setTimeout(() => {
delete agent.operations['s:' + correlation.span_id];
refreshThinkingStream(agent);
}, 3000);
}
}
if (eventType === 'run.start' && correlation.run_id) {
const payload = getEnvelopePayload(evt);
agent.operations['r:' + correlation.run_id] = {
type: 'run',
name: 'Thinking…',
kind: 'run',
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
promptPreview: payload.prompt_preview || payload.message_preview || payload.message || '',
runID: correlation.run_id,
};
}
if (eventType === 'run.end' && correlation.run_id) {
const op = agent.operations['r:' + correlation.run_id];
if (op) {
const payload = getEnvelopePayload(evt);
op.endedAt = new Date(getEnvelopeTS(evt)).getTime() || Date.now();
op.status = payload.status || '';
op.usage = payload.usage || null;
op.model = payload.model || '';
op.thinkingTokens = (payload.usage && payload.usage.thinking_tokens) || 0;
setTimeout(() => {
delete agent.operations['r:' + correlation.run_id];
refreshThinkingStream(agent);
}, 2000);
}
}
const id = getRecordID(evt);
if (id && !agent.eventIDs.has(id)) {
agent.eventIDs.add(id);
agent.events.push(evt);
while (agent.events.length > 100) {
const removed = agent.events.shift();
agent.eventIDs.delete(getRecordID(removed));
}
}
}
function getAgentDisplayOps(agent) {
const now = Date.now();
const ops = Object.values(agent.operations).filter(op => (now - op.startedAt) < 300000);
const hasSpecificSpans = ops.some(op => op.kind && op.kind !== 'run');
return hasSpecificSpans ? ops.filter(op => op.kind && op.kind !== 'run') : ops;
}
function buildAgentActivityBars(agent, bucketCount) {
const events = agent.events || [];
if (events.length === 0) return '';
const count = bucketCount || 20;
const now = Date.now();
const windowMS = 3600000; // 1 hour
const bucketMS = windowMS / count;
const buckets = new Array(count).fill(0);
for (const evt of events) {
const ts = new Date(getEnvelopeTS(evt)).getTime();
const age = now - ts;
if (age > windowMS || age < 0) continue;
const idx = Math.min(count - 1, Math.floor((windowMS - age) / bucketMS));
buckets[idx]++;
}
const max = Math.max(...buckets, 1);
return `<div class="agent-lane-sparkline">${buckets.map(b => {
const pct = (b / max * 100).toFixed(0);
return `<div class="agent-lane-sparkline-bar" style="height:${Math.max(pct, 3)}%"></div>`;
}).join('')}</div>`;
}
function renderAgentLanes() {
const contentEl = document.getElementById('agents-content');
if (!contentEl) return;
contentEl.innerHTML = '<div class="agent-lanes" id="agents-lanes"></div>';
const lanesEl = document.getElementById('agents-lanes');
if (!lanesEl) return;
const agentKeys = getSortedAgentKeys();
if (agentKeys.length === 0) {
lanesEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
return;
}
lanesEl.innerHTML = agentKeys.map(key => {
const agent = agentsState.agents[key];
const isOnline = isAgentOnline(agent);
const sessionCount = Object.keys(agent.sessions).length;
const ops = getAgentDisplayOps(agent);
const subagentCount = ops.filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
const statusClass = sessionCount > 0 ? ' has-sessions' : '';
const statusText = !isOnline ? 'offline'
: subagentCount > 0 ? subagentCount + ' subagent' + (subagentCount > 1 ? 's' : '')
: sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '')
: 'idle';
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
const stale = elapsed > 300;
const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent' : '';
return `
<div class="active-op${stale ? ' stale' : ''}${kindClass}">
<span class="active-op-dot"></span>
<span class="active-op-name">${escapeHTML(op.name)}</span>
<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
${stale ? '<span class="active-op-stale">(stale?)</span>' : ''}
</div>`;
}).join('')}</div>` : '';
const recent = agent.events.slice(-40).reverse();
const eventsHTML = recent.length > 0 ? recent.map(evt => {
const eventType = getEnvelopeType(evt);
const details = getEventDetails(evt);
const detailHTML = details ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
return `
<div class="timeline-event">
<div class="timeline-event-header">
${getEventIcon(eventType)}
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
</div>
${getEventBody(evt)}
${expandHTML}
${detailHTML}
</div>`;
}).join('') : '<p class="empty-state">No recent activity</p>';
return `
<div class="agent-lane" data-agent-key="${escapeHTML(key)}">
<div class="agent-lane-header">
<div>
<div class="agent-lane-name">
<span class="agent-lane-dot ${isOnline ? 'online' : 'offline'}"></span>
${escapeHTML(agent.name || key)}
</div>
<div class="agent-lane-meta">${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}</div>
${buildAgentActivityBars(agent)}
</div>
<span class="agent-lane-status${statusClass}">${statusText}</span>
</div>
${opsHTML}
<div class="agent-lane-events">${eventsHTML}</div>
</div>`;
}).join('');
lanesEl.querySelectorAll('.agent-lane[data-agent-key]').forEach(lane => {
lane.addEventListener('click', () => {
selectAgent(lane.dataset.agentKey || '', 'live');
});
});
lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => {
button.addEventListener('click', event => {
event.stopPropagation();
button.parentElement.classList.toggle('expanded');
});
});
}
function renderAgentSummary() {
const el = document.getElementById('agents-summary');
if (!el) return;
const s = agentsState.dbStats;
const liveAgents = getSortedAgentKeys().filter(key => isAgentOnline(agentsState.agents[key])).length;
const liveSubagents = getSortedAgentKeys().reduce((count, key) => {
const agent = agentsState.agents[key];
return count + Object.values(agent.operations).filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
}, 0);
el.innerHTML = `
<div class="agents-summary-stat">Live Agents <span class="value">${liveAgents}</span></div>
<div class="agents-summary-stat">Active Subagents <span class="value">${liveSubagents}</span></div>
<div class="agents-summary-stat">Runs Today <span class="value">${s.messages}</span></div>
<div class="agents-summary-stat">Tool Calls <span class="value">${s.tools}</span></div>
<div class="agents-summary-stat">Errors <span class="value">${s.errors}</span></div>
`;
}
function getAgentLabel(agent) {
if (!agent) return 'Unknown';
return agent.name || agent.host || agent.framework || agent.key || 'Unknown';
}
function getAgentLiveSummary(agent) {
const recent = agent.events.slice().reverse();
const activeOps = getAgentDisplayOps(agent);
const sessionIDs = Object.keys(agent.sessions);
const live = {
sessionIDs,
activeOps,
activeSubagents: activeOps.filter(op => op.kind === 'agent' || op.subType === 'subagent'),
activeTools: activeOps.filter(op => op.kind === 'tool'),
latestPrompt: '',
latestRunStatus: '',
latestModel: '',
latestError: '',
latestUsage: null,
latestContextWindow: null,
};
for (const evt of recent) {
const eventType = getEnvelopeType(evt);
const payload = getEnvelopePayload(evt);
if (!live.latestPrompt && eventType === 'run.start') {
live.latestPrompt = payload.prompt_preview || payload.message_preview || payload.message || '';
}
if (!live.latestRunStatus && eventType === 'run.end') {
live.latestRunStatus = payload.status || '';
live.latestModel = payload.model || '';
live.latestUsage = payload.usage || null;
live.latestContextWindow = payload.context_window || null;
}
if (!live.latestUsage && eventType === 'metric.snapshot' && payload.metrics) {
live.latestUsage = payload.metrics.usage || null;
live.latestModel = live.latestModel || payload.metrics.model || '';
}
if (!live.latestError && eventType === 'error') {
const errPayload = payload.error || {};
live.latestError = errPayload.message || payload.message || '';
}
if (live.latestPrompt && live.latestRunStatus && live.latestError) break;
}
return live;
}
function buildLiveEventContext(evt) {
const eventType = getEnvelopeType(evt);
const payload = getEnvelopePayload(evt);
const attrs = getEnvelopeAttributes(evt);
const correlation = getEnvelopeCorrelation(evt);
const parts = [];
if ((eventType === 'span.start' || eventType === 'span.end') && attrs.span_kind === 'tool') {
if (payload.input) {
parts.push(`<div class="live-detail-row"><span class="k">input</span><span class="v">${escapeHTML(typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input))}</span></div>`);
}
if (payload.result_preview) {
parts.push(`<div class="live-detail-row"><span class="k">result</span><span class="v">${escapeHTML(String(payload.result_preview))}</span></div>`);
}
}
if ((eventType === 'span.start' || eventType === 'span.end') && (attrs.span_kind === 'agent' || attrs.type === 'subagent')) {
if (payload.prompt_preview) {
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(payload.prompt_preview))}</span></div>`);
}
if (payload.usage && payload.usage.total_tokens !== undefined) {
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
}
if (payload.usage && payload.usage.total_cost !== undefined) {
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.usage.total_cost))}</span></div>`);
}
}
if (eventType === 'run.start') {
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
if (preview) {
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(preview))}</span></div>`);
}
}
if (eventType === 'run.end') {
if (payload.model) {
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.model))}</span></div>`);
}
if (payload.usage && payload.usage.total_tokens !== undefined) {
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
}
if (payload.duration_ms !== undefined) {
parts.push(`<div class="live-detail-row"><span class="k">duration</span><span class="v">${escapeHTML(formatDuration(payload.duration_ms))}</span></div>`);
}
}
if (eventType === 'metric.snapshot' && payload.metrics) {
if (payload.metrics.model) {
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.metrics.model))}</span></div>`);
}
if (payload.metrics.usage && payload.metrics.usage.total_tokens !== undefined) {
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.metrics.usage.total_tokens))}</span></div>`);
}
if (payload.metrics.usage && payload.metrics.usage.total_cost !== undefined) {
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.metrics.usage.total_cost))}</span></div>`);
}
}
if (eventType === 'error') {
const errPayload = payload.error || {};
if (errPayload.type) {
parts.push(`<div class="live-detail-row"><span class="k">type</span><span class="v">${escapeHTML(String(errPayload.type))}</span></div>`);
}
}
const ids = [];
if (correlation.session_id) ids.push(`session ${correlation.session_id}`);
if (correlation.run_id) ids.push(`run ${correlation.run_id}`);
if (correlation.span_id) ids.push(`span ${correlation.span_id}`);
if (ids.length > 0) {
parts.push(`<div class="live-detail-row ids"><span class="k">ids</span><span class="v">${escapeHTML(ids.join(' · '))}</span></div>`);
}
return parts.join('');
}
function getRunGroupLabel(runID, events) {
const runStart = events.find(evt => getEnvelopeType(evt) === 'run.start');
if (!runStart) {
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
}
const payload = getEnvelopePayload(runStart);
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
if (preview) {
return preview.length > 72 ? preview.slice(0, 72) + '...' : preview;
}
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
}
function groupAgentEventsByRun(events) {
const groups = [];
const byRun = new Map();
for (const evt of events) {
const correlation = getEnvelopeCorrelation(evt);
const runID = correlation.run_id || '';
const key = runID || `session:${correlation.session_id || 'unknown'}`;
if (!byRun.has(key)) {
const group = {
key,
runID,
sessionID: correlation.session_id || '',
events: [],
subagents: new Set(),
tools: new Set(),
};
byRun.set(key, group);
groups.push(group);
}
const group = byRun.get(key);
group.events.push(evt);
const attrs = getEnvelopeAttributes(evt);
if (attrs.span_kind === 'agent' || attrs.type === 'subagent') {
group.subagents.add(attrs.name || 'unknown');
}
if (attrs.span_kind === 'tool' && attrs.name) {
group.tools.add(attrs.name);
}
}
return groups;
}
function refreshThinkingStream(agent) {
if (!agent) return;
const selectedKey = agentsState.selectedAgentKey;
if (agent.key !== selectedKey) return;
const streamEl = document.getElementById('thinking-stream-' + selectedKey);
if (streamEl) {
streamEl.innerHTML = renderThinkingStream(agent);
}
}
function renderThinkingStream(agent) {
const now = Date.now();
const ops = Object.values(agent.operations).filter(op => {
if (op.endedAt) return (now - op.endedAt) < 3000;
return (now - op.startedAt) < 300000;
});
if (ops.length === 0) {
return '<div class="thinking-stream-empty">Idle — waiting for activity</div>';
}
return ops.map(op => {
const elapsed = op.endedAt
? Math.floor((op.endedAt - op.startedAt) / 1000)
: Math.floor((now - op.startedAt) / 1000);
const isEnded = !!op.endedAt;
const isSubagent = op.kind === 'agent' || op.subType === 'subagent';
const isRun = op.kind === 'run';
const isTool = op.kind === 'tool';
let icon, kindLabel, kindClass;
if (isRun) {
icon = isEnded ? '✓' : '◌';
kindLabel = isEnded ? (op.status === 'success' ? 'Done' : op.status || 'Done') : 'Thinking';
kindClass = 'thinking-op-run' + (isEnded ? ' ended' : ' active');
} else if (isSubagent) {
icon = isEnded ? '✓' : '◎';
kindLabel = isEnded ? (op.status === 'success' ? 'Subagent done' : 'Subagent ' + (op.status || 'done')) : 'Subagent';
kindClass = 'thinking-op-subagent' + (isEnded ? ' ended' : ' active');
} else if (isTool) {
icon = isEnded ? '✓' : '▸';
kindLabel = isEnded ? (op.status === 'success' ? 'Tool done' : 'Tool ' + (op.status || 'done')) : 'Tool';
kindClass = 'thinking-op-tool' + (isEnded ? ' ended' : ' active');
} else {
icon = '·';
kindLabel = op.name;
kindClass = 'thinking-op-other' + (isEnded ? ' ended' : ' active');
}
const preview = op.promptPreview || op.inputPreview || '';
const result = op.resultPreview || '';
const usage = op.usage || {};
const thinkingToks = op.thinkingTokens || usage.thinking_tokens || 0;
const totalToks = usage.total_tokens || 0;
const navigableRunID = isRun ? op.runID : (isSubagent ? op.runID : '');
const clickable = navigableRunID ? ` clickable" data-run-id="${escapeHTML(navigableRunID)}` : '';
return `
<div class="thinking-op ${kindClass}${clickable ? ' thinking-op-link' : ''}"${clickable ? ` data-run-id="${escapeHTML(navigableRunID)}"` : ''}>
<div class="thinking-op-header">
<span class="thinking-op-icon${isRun && !isEnded ? ' spin' : ''}">${icon}</span>
<span class="thinking-op-kind">${escapeHTML(kindLabel)}</span>
<span class="thinking-op-name">${escapeHTML(op.name)}</span>
<span class="thinking-op-elapsed${isEnded ? '' : ' live'}" data-start="${op.startedAt}" data-ended="${op.endedAt || ''}">
${isEnded ? formatElapsed(elapsed) : `<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>`}
</span>
${navigableRunID ? '<span class="thinking-op-arrow">→</span>' : ''}
</div>
${preview ? `<div class="thinking-op-preview">${escapeHTML(preview.length > 180 ? preview.slice(0, 180) + '…' : preview)}</div>` : ''}
${result ? `<div class="thinking-op-result">${escapeHTML(result.length > 180 ? result.slice(0, 180) + '…' : result)}</div>` : ''}
${(thinkingToks || totalToks) ? `<div class="thinking-op-tokens">${thinkingToks ? `<span class="thinking-tok-badge">🧠 ${formatTokenCount(thinkingToks)} thinking</span>` : ''}${totalToks ? `<span class="thinking-tok-badge">⚡ ${formatTokenCount(totalToks)} total</span>` : ''}</div>` : ''}
</div>`;
}).join('');
}
function renderAgentsLive() {
const contentEl = document.getElementById('agents-content');
if (!contentEl) return;
const agentKeys = getSortedAgentKeys();
const selectedKey = ensureSelectedAgentKey();
if (!selectedKey || agentKeys.length === 0) {
contentEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
return;
}
const selected = agentsState.agents[selectedKey];
const summary = getAgentLiveSummary(selected);
const recent = selected.events.slice(-80).reverse();
const runGroups = groupAgentEventsByRun(recent);
contentEl.innerHTML = `
<div class="agents-live-layout">
<aside class="agents-live-sidebar">
<div class="section-title">Agents</div>
<div class="agent-picker" id="agent-picker">
${agentKeys.map(key => {
const agent = agentsState.agents[key];
const active = key === selectedKey ? ' active' : '';
const online = isAgentOnline(agent) ? 'online' : 'offline';
const sessions = Object.keys(agent.sessions).length;
const ops = getAgentDisplayOps(agent).length;
return `
<button class="agent-picker-item${active}" data-agent-key="${escapeHTML(key)}" type="button">
<span class="agent-picker-dot ${online}"></span>
<span class="agent-picker-main">
<span class="agent-picker-name">${escapeHTML(getAgentLabel(agent))}</span>
<span class="agent-picker-meta">${escapeHTML(agent.framework || 'unknown')} · ${sessions} sessions · ${ops} ops</span>
</span>
</button>`;
}).join('')}
</div>
</aside>
<section class="agents-live-main">
<div class="agents-live-header">
<div>
<div class="agent-lane-name"><span class="agent-lane-dot ${isAgentOnline(selected) ? 'online' : 'offline'}"></span>${escapeHTML(getAgentLabel(selected))}</div>
<div class="agent-lane-meta">${escapeHTML(selected.framework || 'unknown')}${selected.host && selected.host !== selected.name ? ' · ' + escapeHTML(selected.host) : ''}</div>
</div>
<div class="agents-live-badges">
<span class="agents-live-badge">${summary.sessionIDs.length} sessions</span>
<span class="agents-live-badge">${summary.activeSubagents.length} subagents</span>
<span class="agents-live-badge">${summary.activeTools.length} tools</span>
</div>
</div>
<div class="agents-live-cards">
<div class="agents-live-card thinking-stream-card">
<div class="agents-live-card-title">
Live Operations
${summary.activeOps.length > 0 ? `<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>${summary.activeOps.length} active</span>` : ''}
</div>
<div class="thinking-stream" id="thinking-stream-${escapeHTML(selectedKey)}">
${renderThinkingStream(selected)}
</div>
</div>
<div class="agents-live-card">
<div class="agents-live-card-title">Last Run</div>
<div class="live-kv"><span>Status</span><strong>${escapeHTML(summary.latestRunStatus || '—')}</strong></div>
<div class="live-kv"><span>Model</span><strong>${escapeHTML(summary.latestModel || '—')}</strong></div>
<div class="live-kv"><span>Tokens</span><strong>${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.total_tokens : null))}</strong></div>
<div class="live-kv"><span>Thinking</span><strong class="thinking-toks">${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.thinking_tokens : null))}</strong></div>
<div class="live-kv"><span>Cost</span><strong>${escapeHTML(formatCost(summary.latestUsage ? summary.latestUsage.total_cost : null))}</strong></div>
${summary.latestError ? `<div class="live-kv error-kv"><span>Error</span><strong>${escapeHTML(summary.latestError)}</strong></div>` : ''}
</div>
<div class="agents-live-card">
<div class="agents-live-card-title">Context Window</div>
<div class="live-kv"><span>Input</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.input_tokens : null))}</strong></div>
<div class="live-kv"><span>Output</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.output_tokens : null))}</strong></div>
<div class="live-kv"><span>Used</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.used_tokens : null))}</strong></div>
<div class="live-kv"><span>Remaining</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.tokens_remaining : null))}</strong></div>
${summary.latestContextWindow && summary.latestContextWindow.max_tokens ? `
<div class="context-bar">
<div class="context-bar-fill" style="width:${Math.min(100, ((summary.latestContextWindow.used_tokens || 0) / summary.latestContextWindow.max_tokens * 100)).toFixed(1)}%"></div>
</div>` : ''}
</div>
</div>
<div class="agents-live-timeline">
${runGroups.length > 0 ? runGroups.map(group => `
<section class="live-run-group">
<div class="live-run-group-header">
<div class="live-run-group-title">${escapeHTML(getRunGroupLabel(group.runID, group.events))}</div>
<div class="live-run-group-meta">
<span>${escapeHTML(group.runID ? `run ${group.runID.slice(0, 12)}...` : 'session-only')}</span>
<span>${escapeHTML(group.subagents.size > 0 ? `${group.subagents.size} subagents` : '0 subagents')}</span>
<span>${escapeHTML(group.tools.size > 0 ? `${group.tools.size} tools` : '0 tools')}</span>
</div>
</div>
<div class="live-run-events">
${group.events.map(evt => `
<div class="timeline-event live-event">
<div class="timeline-event-header">
${getEventIcon(getEnvelopeType(evt))}
<span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span>
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
</div>
${getEventBody(evt)}
<div class="live-detail-grid">${buildLiveEventContext(evt)}</div>
</div>`).join('')}
</div>
</section>
`).join('') : '<p class="empty-state">No recent activity</p>'}
</div>
</section>
</div>
`;
contentEl.querySelectorAll('[data-agent-key]').forEach(button => {
button.addEventListener('click', () => selectAgent(button.dataset.agentKey || '', 'live'));
});
const mainSection = contentEl.querySelector('.agents-live-main');
if (mainSection) {
mainSection.addEventListener('click', e => {
const op = e.target.closest('.thinking-op[data-run-id]');
if (op && op.dataset.runId) {
navigate('/runs/' + op.dataset.runId);
}
});
}
}
function scheduleAgentsRender() {
if (_agentsRenderTimer) return;
_agentsRenderTimer = requestAnimationFrame(() => {
_agentsRenderTimer = null;
renderAgentsContent();
});
}
function handleAgentsWS(msg) {
if (msg.type !== 'message') return;
const eventType = getEnvelopeType(msg.data);
if (eventType === 'openclaw.snapshot') {
mergeOpenClawEvents([msg.data]);
scheduleAgentsRender();
return;
}
if (!isAgentTimelineEvent(msg.data)) return;
if (eventType === 'run.start') agentsState.dbStats.messages++;
else if (eventType === 'span.end') {
const attrs = getEnvelopeAttributes(msg.data);
if (attrs.span_kind === 'tool') agentsState.dbStats.tools++;
} else if (eventType === 'error') agentsState.dbStats.errors++;
addAgentEvents([msg.data]);
scheduleAgentsRender();
}
function updateAgentTimers() {
document.querySelectorAll('.active-op-time[data-start]').forEach(el => {
const start = parseInt(el.dataset.start, 10);
if (!start) return;
const elapsed = Math.floor((Date.now() - start) / 1000);
el.textContent = formatElapsed(elapsed);
const op = el.closest('.active-op');
if (op && elapsed > 300 && !op.classList.contains('stale')) {
op.classList.add('stale');
if (!op.querySelector('.active-op-stale')) {
op.insertAdjacentHTML('beforeend', '<span class="active-op-stale">(stale?)</span>');
}
}
});
}
function addAgentEvents(events) {
let changed = false;
for (const evt of events) {
const id = getRecordID(evt);
const agent = getAgentBucket(evt);
if (!id || !agent || agent.eventIDs.has(id)) continue;
processAgentEvent(evt);
changed = true;
}
if (changed) {
for (const agent of Object.values(agentsState.agents)) {
agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
}
recomputeAgentStats();
}
}
function recomputeAgentStats() {
const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} };
for (const agent of Object.values(agentsState.agents)) {
for (const evt of agent.events) {
const eventType = getEnvelopeType(evt);
const attrs = getEnvelopeAttributes(evt);
if (eventType === 'run.start' || eventType === 'run.end') stats.messages++;
if (eventType === 'span.end' && attrs.span_kind === 'tool') {
stats.tools++;
const toolName = attrs.name || 'unknown';
stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1;
}
if (eventType === 'error') stats.errors++;
}
}
agentsState.stats = stats;
}
function bindAgentViewToggle() {
const root = document.getElementById('agents-view-toggle');
if (!root) return;
root.querySelectorAll('[data-mode]').forEach(button => {
button.addEventListener('click', () => {
setAgentsViewMode(button.dataset.mode || 'overview');
});
});
}
function updateAgentViewToggle() {
const root = document.getElementById('agents-view-toggle');
if (!root) return;
root.querySelectorAll('[data-mode]').forEach(button => {
button.classList.toggle('active', button.dataset.mode === agentsState.viewMode);
});
}
function renderAgentsContent() {
renderAgentSummary();
updateAgentViewToggle();
if (agentsState.viewMode === 'live') {
renderAgentsLive();
return;
}
renderAgentLanes();
}
async function loadSelectedAgentLiveData() {
const selectedKey = ensureSelectedAgentKey();
if (!selectedKey) return;
const agent = agentsState.agents[selectedKey];
if (!agent || agent.liveLoaded || agent.liveLoading || !agent.clientID || !agent.framework) {
return;
}
agent.liveLoading = true;
try {
const params = new URLSearchParams();
params.set('client_id', agent.clientID);
params.set('framework', agent.framework);
params.set('limit', '250');
const data = await api('/v1/agents/live?' + params.toString());
addAgentEvents((data.events || []).slice().reverse());
agent.liveLoaded = true;
} catch (err) {
console.error('Failed to load live agent context:', err);
} finally {
agent.liveLoading = false;
if (isCurrentPath('/agents') && agentsState.viewMode === 'live' && agentsState.selectedAgentKey === selectedKey) {
renderAgentsContent();
}
}
}
// ── Exports ──────────────────────────────────────────────
export async function renderAgents(initialKey, routeToken) {
resetAgentsState();
app.innerHTML = `
<div class="page-header">
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
</div>
<div class="agents-toolbar">
<div class="view-toggle" id="agents-view-toggle">
<button class="view-toggle-btn active" data-mode="overview" type="button">Overview</button>
<button class="view-toggle-btn" data-mode="live" type="button">Live</button>
</div>
</div>
<div class="agents-summary-row" id="agents-summary"></div>
<div id="agents-content">${agentsSkeleton()}</div>
`;
bindAgentViewToggle();
try {
const [snapshots, events, summaryData] = await Promise.all([
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
api('/v1/events?limit=300'),
api('/v1/stats/summary').catch(() => null),
]);
if ((routeToken && !isRouteCurrent(routeToken)) || !isCurrentPath('/agents')) return;
if (summaryData) {
agentsState.dbStats.messages = summaryData.runs_today || 0;
agentsState.dbStats.tools = summaryData.tool_calls_today || 0;
agentsState.dbStats.errors = summaryData.errors_today || 0;
}
mergeOpenClawEvents(snapshots.events || []);
addAgentEvents((events.events || []).filter(isAgentTimelineEvent).slice().reverse());
if (initialKey && agentsState.agents[initialKey]) {
agentsState.selectedAgentKey = initialKey;
renderBreadcrumbs();
}
renderAgentsContent();
} catch (e) {
if (routeToken && !isRouteCurrent(routeToken)) return;
document.getElementById('agents-content').innerHTML =
`<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
}
if (!routeToken || isRouteCurrent(routeToken)) {
agentsState.timerInterval = setInterval(updateAgentTimers, 1000);
agentsUnsubscribe = subscribeWS(handleAgentsWS);
}
}
export function selectAgent(key, nextMode) {
if (!key || !agentsState.agents[key]) return;
agentsState.selectedAgentKey = key;
if (nextMode) {
agentsState.viewMode = nextMode;
}
const newPath = '/agents/' + encodeURIComponent(key);
if (window.location.pathname !== newPath) {
history.pushState(null, '', newPath);
renderBreadcrumbs();
}
renderAgentsContent();
if (agentsState.viewMode === 'live') {
void loadSelectedAgentLiveData();
}
}
export function cleanup() {
if (agentsState.timerInterval) {
clearInterval(agentsState.timerInterval);
agentsState.timerInterval = null;
}
if (agentsUnsubscribe) {
agentsUnsubscribe();
agentsUnsubscribe = null;
}
if (_agentsRenderTimer) {
cancelAnimationFrame(_agentsRenderTimer);
_agentsRenderTimer = null;
}
}