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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user