diff --git a/cmd/web-ui/static/app.js b/cmd/web-ui/static/app.js
index 22cd1c3..bc213bb 100644
--- a/cmd/web-ui/static/app.js
+++ b/cmd/web-ui/static/app.js
@@ -1,4 +1,43 @@
(function() {
+
+ // ── Theme toggle ─────────────────────────────────────────
+ const THEME_CYCLE = ['system', 'light', 'dark'];
+ const THEME_ICONS = {
+ system: '',
+ light: '',
+ dark: '',
+ };
+ const THEME_LABELS = { system: 'System theme', light: 'Light theme', dark: 'Dark theme' };
+
+ function getTheme() { return localStorage.getItem('theme') || 'system'; }
+
+ function applyTheme(theme) {
+ if (theme === 'system') document.documentElement.removeAttribute('data-theme');
+ else document.documentElement.setAttribute('data-theme', theme);
+ }
+
+ function updateToggleBtn(theme) {
+ const btn = document.getElementById('theme-toggle');
+ if (!btn) return;
+ btn.innerHTML = THEME_ICONS[theme];
+ btn.title = THEME_LABELS[theme];
+ }
+
+ function cycleTheme() {
+ const next = THEME_CYCLE[(THEME_CYCLE.indexOf(getTheme()) + 1) % THEME_CYCLE.length];
+ if (next === 'system') localStorage.removeItem('theme');
+ else localStorage.setItem('theme', next);
+ applyTheme(next);
+ updateToggleBtn(next);
+ }
+
+ document.addEventListener('DOMContentLoaded', function() {
+ updateToggleBtn(getTheme());
+ const btn = document.getElementById('theme-toggle');
+ if (btn) btn.addEventListener('click', cycleTheme);
+ });
+ // ─────────────────────────────────────────────────────────
+
const app = document.getElementById('app');
let ws = null;
@@ -6,8 +45,10 @@
const wsCallbacks = new Set();
let sessionsState = { sessions: [], cursor: null };
+ let sessionsUnsubscribe = null;
let openclawState = { instances: {} };
let openclawUnsubscribe = null;
+ let swarmState = { services: {} }; // keyed by service name
let agentsState = createAgentsState();
let agentsUnsubscribe = null;
let dashboardState = null;
@@ -74,6 +115,14 @@
agentsUnsubscribe();
agentsUnsubscribe = null;
}
+ if (sessionsState && sessionsState.timerInterval) {
+ clearInterval(sessionsState.timerInterval);
+ sessionsState.timerInterval = null;
+ }
+ if (sessionsUnsubscribe) {
+ sessionsUnsubscribe();
+ sessionsUnsubscribe = null;
+ }
if (dashboardUnsubscribe) {
dashboardUnsubscribe();
dashboardUnsubscribe = null;
@@ -86,6 +135,10 @@
dashboardResizeObserver.disconnect();
dashboardResizeObserver = null;
}
+ if (agentsState && agentsState.timerInterval) {
+ clearInterval(agentsState.timerInterval);
+ agentsState.timerInterval = null;
+ }
}
function route() {
@@ -136,6 +189,8 @@
return resp.json();
}
+ function tryParseJSON(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
+
function escapeHTML(value) {
return String(value ?? '')
.replace(/&/g, '&')
@@ -277,8 +332,96 @@
document.getElementById('load-more').addEventListener('click', loadSessions);
- sessionsState = { sessions: [], cursor: null };
+ sessionsState = { sessions: [], cursor: null, timerInterval: null };
await loadSessions();
+
+ sessionsState.timerInterval = setInterval(updateSessionTimers, 30000);
+ sessionsUnsubscribe = subscribeWS(handleSessionsWS);
+ }
+
+ function isSessionActive(s) { return !s.ended_at; }
+
+ function renderSessionRow(s) {
+ const fw = s.framework || 'unknown';
+ const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
+ const active = isSessionActive(s);
+ const dotState = active ? 'active' : 'ended';
+ const dotTitle = active ? 'Active session' : 'Session ended';
+ return `
+
-
-
Loading agent activity...
-
-
-
-
Messages
-
0
-
received and sent
-
-
-
-
-
+
+
`;
- renderAgentVMStrip();
-
try {
- const [snapshots, events] = await Promise.all([
+ const [snapshots, events, summaryData] = await Promise.all([
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
api('/v1/events?framework=openclaw&limit=200'),
+ api('/v1/stats/summary').catch(() => null),
]);
- if (!isCurrentPath('/agents')) {
- return;
+ if (!isCurrentPath('/agents')) return;
+
+ if (summaryData) {
+ const fw = (summaryData.by_framework || {}).openclaw || {};
+ agentsState.dbStats.messages = fw.runs || 0;
+ agentsState.dbStats.tools = fw.tools || 0;
+ agentsState.dbStats.errors = fw.errors || 0;
}
mergeOpenClawEvents(snapshots.events || []);
- renderAgentVMStrip();
addAgentEvents((events.events || []).slice().reverse());
- renderAgentTimeline();
- renderAgentStats();
+ renderAgentLanes();
+ renderAgentSummary();
} catch (e) {
- const timeline = document.getElementById('agents-timeline');
- if (timeline) {
- timeline.innerHTML = `
Error loading agent activity: ${escapeHTML(e.message)}
`;
- }
+ document.getElementById('agents-lanes').innerHTML =
+ `
Error loading agent activity: ${escapeHTML(e.message)}
`;
}
+ agentsState.timerInterval = setInterval(updateAgentTimers, 1000);
agentsUnsubscribe = subscribeWS(handleAgentsWS);
}
- function renderAgentVMStrip() {
- const strip = document.getElementById('agents-vm-strip');
- if (!strip) {
- return;
- }
+ function renderAgentLanes() {
+ const lanesEl = document.getElementById('agents-lanes');
+ if (!lanesEl) return;
- const vms = getVMStatus();
- strip.innerHTML = vms.map(vm => `
-
-
- ${escapeHTML(vm.name)}
- ${vm.active ? 'online' : 'offline'}
-
- `).join('');
+ const vmNames = ['zap', 'orb', 'sun'];
+
+ lanesEl.innerHTML = vmNames.map(name => {
+ const agent = agentsState.agents[name];
+ const vmStatus = getVMStatus().find(v => v.name === name);
+ const isOnline = vmStatus && vmStatus.active;
+ const sessionCount = Object.keys(agent.sessions).length;
+ const ops = getAgentDisplayOps(agent);
+
+ const statusClass = sessionCount > 0 ? ' has-sessions' : '';
+ const statusText = !isOnline ? 'offline'
+ : sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '')
+ : 'idle';
+
+ const opsHTML = ops.length > 0 ? `
${ops.map(op => {
+ const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
+ const stale = elapsed > 300;
+ return `
+
+
+ ${escapeHTML(op.name)}
+ ${formatElapsed(elapsed)}
+ ${stale ? '(stale?)' : ''}
+
`;
+ }).join('')}
` : '';
+
+ const recent = agent.events.slice(-40).reverse();
+ const eventsHTML = recent.length > 0 ? recent.map(evt => {
+ const eventType = getEnvelopeType(evt);
+ const vmClass = getVMClassName(name);
+ const details = getEventDetails(evt);
+ const detailHTML = details ? `
${escapeHTML(details)}
` : '';
+ const expandHTML = details ? '
' : '';
+
+ return `
+
+
+ ${getEventBody(evt)}
+ ${expandHTML}
+ ${detailHTML}
+
`;
+ }).join('') : '
No recent activity
';
+
+ return `
+
+
+ ${opsHTML}
+
${eventsHTML}
+
`;
+ }).join('');
+
+ lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => {
+ button.addEventListener('click', () => {
+ button.parentElement.classList.toggle('expanded');
+ });
+ });
+ }
+
+ 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';
+ }
+
+ function renderAgentSummary() {
+ const el = document.getElementById('agents-summary');
+ if (!el) return;
+ const s = agentsState.dbStats;
+ el.innerHTML = `
+
Runs Today ${s.messages}
+
Tool Calls ${s.tools}
+
Errors ${s.errors}
+ `;
+ }
+
+ function renderAgentVMStrip() {
+ // VM online/offline state is shown in each lane header via getVMStatus().
+ // Re-render lanes to pick up the updated openclawState.
+ renderAgentLanes();
}
function handleAgentsWS(msg) {
- if (msg.type !== 'message') {
- return;
- }
+ if (msg.type !== 'message') return;
const eventType = getEnvelopeType(msg.data);
if (eventType === 'openclaw.snapshot') {
mergeOpenClawEvents([msg.data]);
- renderAgentVMStrip();
+ renderAgentLanes();
return;
}
const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework;
- if (framework !== 'openclaw') {
- return;
- }
+ if (framework !== 'openclaw') 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]);
- renderAgentTimeline();
- renderAgentStats();
+ renderAgentLanes();
+ renderAgentSummary();
+ }
+
+ 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', '
(stale?)');
+ }
+ }
+ });
}
function addAgentEvents(events) {
@@ -722,52 +1071,35 @@
for (const evt of events) {
const id = getRecordID(evt);
- if (!id || agentsState.eventIDs.has(id)) {
- continue;
- }
- agentsState.eventIDs.add(id);
- agentsState.events.push(evt);
+ const agent = getAgentBucket(evt);
+ if (!id || !agent || agent.eventIDs.has(id)) continue;
+ processAgentEvent(evt);
changed = true;
}
- if (!changed) {
- return;
+ 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();
}
-
- agentsState.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
-
- while (agentsState.events.length > 500) {
- const removed = agentsState.events.shift();
- agentsState.eventIDs.delete(getRecordID(removed));
- }
-
- recomputeAgentStats();
}
function recomputeAgentStats() {
- const stats = {
- messages: 0,
- tools: 0,
- errors: 0,
- toolCounts: {},
- };
+ const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} };
- for (const evt of agentsState.events) {
- const eventType = getEnvelopeType(evt);
- const attrs = getEnvelopeAttributes(evt);
+ 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++;
+ 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++;
}
}
@@ -882,88 +1214,6 @@
return JSON.stringify(details, null, 2);
}
- function renderAgentTimeline() {
- const timeline = document.getElementById('agents-timeline');
- if (!timeline) {
- return;
- }
-
- const recent = agentsState.events.slice(-100).reverse();
- if (recent.length === 0) {
- timeline.innerHTML = '
Waiting for agent activity...
';
- return;
- }
-
- timeline.innerHTML = recent.map((evt, index) => {
- const eventType = getEnvelopeType(evt);
- const vmName = getVMName(evt);
- const vmClass = getVMClassName(vmName);
- const details = getEventDetails(evt);
- const detailHTML = details ? `
${escapeHTML(details)}
` : '';
- const expandHTML = details ? '
' : '';
-
- return `
-
-
- ${getEventBody(evt)}
- ${expandHTML}
- ${detailHTML}
-
- `;
- }).join('');
-
- timeline.querySelectorAll('.timeline-expand-hint').forEach(button => {
- button.addEventListener('click', () => {
- button.parentElement.classList.toggle('expanded');
- });
- });
- }
-
- function renderAgentStats() {
- const stats = agentsState.stats;
-
- const messagesEl = document.getElementById('stat-messages');
- if (messagesEl) {
- messagesEl.textContent = String(stats.messages);
- }
-
- const toolsEl = document.getElementById('stat-tools');
- if (toolsEl) {
- toolsEl.textContent = String(stats.tools);
- }
-
- const errorsEl = document.getElementById('stat-errors');
- if (errorsEl) {
- errorsEl.textContent = String(stats.errors);
- }
-
- const list = document.getElementById('stat-top-tools');
- if (!list) {
- return;
- }
-
- const topTools = Object.entries(stats.toolCounts)
- .sort((a, b) => b[1] - a[1])
- .slice(0, 8);
-
- if (topTools.length === 0) {
- list.innerHTML = '
No data yet';
- return;
- }
-
- list.innerHTML = topTools.map(([name, count]) => `
-
- ${escapeHTML(name)}
- ${count}
-
- `).join('');
- }
-
async function renderDashboard() {
dashboardState = {
summary: null,
@@ -1001,7 +1251,8 @@