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 ` + ${escapeHTML(s.session_id.substring(0, 12))}… + ${escapeHTML(fw)} + ${escapeHTML(s.host || '-')} + ${s.run_count} + ${escapeHTML(relativeTime(s.started_at))} + `; + } + + function updateSessionTimers() { + const tbody = document.getElementById('sessions-body'); + if (!tbody) return; + sessionsState.sessions.forEach(s => { + const row = tbody.querySelector(`[data-session="${s.session_id}"]`); + if (row) { + const td = row.cells[4]; + if (td) td.textContent = relativeTime(s.started_at); + } + }); + } + + function handleSessionsWS(msg) { + if (msg.type !== 'message') return; + const eventType = getEnvelopeType(msg.data); + const correlation = getEnvelopeCorrelation(msg.data); + const sessionId = correlation?.session_id || msg.data.event?.id; + + if (eventType === 'session.start') { + const source = msg.data.event?.source; + const newSession = { + session_id: sessionId, + started_at: msg.data.event?.ts, + framework: source?.framework || 'unknown', + host: source?.host || '-', + run_count: 1, + active: true, + }; + sessionsState.sessions.unshift(newSession); + const tbody = document.getElementById('sessions-body'); + if (tbody) { + const row = tbody.insertRow(0); + row.className = 'clickable active'; + row.dataset.session = newSession.session_id; + row.innerHTML = renderSessionRow(newSession); + row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session)); + } + } + + if (eventType === 'run.start' && sessionId) { + const session = sessionsState.sessions.find(s => s.session_id === sessionId); + if (session) { + session.run_count = (session.run_count || 0) + 1; + const tbody = document.getElementById('sessions-body'); + if (tbody) { + const row = tbody.querySelector(`[data-session="${sessionId}"]`); + if (row) row.cells[3].textContent = session.run_count; + } + } + } + + if (eventType === 'session.end' && sessionId) { + const session = sessionsState.sessions.find(s => s.session_id === sessionId); + if (session) { + session.ended_at = new Date().toISOString(); + const tbody = document.getElementById('sessions-body'); + if (tbody) { + const row = tbody.querySelector(`[data-session="${sessionId}"]`); + if (row) { + const dot = row.querySelector('.fw-dot'); + dot.classList.remove('active'); + dot.classList.add('ended'); + } + } + } + } } async function loadSessions() { @@ -302,10 +445,13 @@ tbody.innerHTML = sessionsState.sessions.map(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 ` - + ${escapeHTML(s.session_id.substring(0, 12))}… - ${escapeHTML(fw)} + ${escapeHTML(fw)} ${escapeHTML(s.host || '-')} ${s.run_count} ${escapeHTML(relativeTime(s.started_at))} @@ -358,6 +504,8 @@ Run ID Status + Model + Tools Spans Duration Started @@ -368,16 +516,19 @@ const runDuration = r.ended_at ? formatDuration(new Date(r.ended_at) - new Date(r.started_at)) : '-'; + const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-'; return ` ${escapeHTML(r.run_id.substring(0, 12))}... ${statusIcon(r.status)} + ${modelLabel} + ${r.tool_count || 0} ${r.span_count} ${escapeHTML(runDuration)} ${escapeHTML(new Date(r.started_at).toLocaleTimeString())} `; - }).join('') || 'No runs'} + }).join('') || 'No runs'} @@ -391,6 +542,50 @@ e.preventDefault(); navigate('/sessions'); }); + + sessionsUnsubscribe = subscribeWS((msg) => handleSessionWS(sessionID, msg)); + } + + function handleSessionWS(sessionID, msg) { + if (msg.type !== 'message') return; + const correlation = getEnvelopeCorrelation(msg.data); + if (correlation?.session_id !== sessionID) return; + + loadSessionData(sessionID); + } + + async function loadSessionData(sessionID) { + if (!isCurrentPath('/sessions/' + sessionID)) return; + const data = await api('/v1/sessions/' + sessionID); + const runs = data.runs || []; + + const tbody = document.querySelector('#app table tbody'); + if (!tbody) return; + + tbody.innerHTML = runs.map(r => { + const runDuration = r.ended_at + ? formatDuration(new Date(r.ended_at) - new Date(r.started_at)) + : '-'; + const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-'; + return ` + + ${escapeHTML(r.run_id.substring(0, 12))}... + ${statusIcon(r.status)} + ${modelLabel} + ${r.tool_count || 0} + ${r.span_count} + ${escapeHTML(runDuration)} + ${escapeHTML(new Date(r.started_at).toLocaleTimeString())} + + `; + }).join('') || 'No runs'; + + tbody.querySelectorAll('tr.clickable').forEach(row => { + row.addEventListener('click', () => navigate('/runs/' + row.dataset.run)); + }); + + const countSpan = document.querySelector('.section-title .count'); + if (countSpan) countSpan.textContent = runs.length; } async function renderRun(runID) { @@ -521,6 +716,20 @@ } } + function mergeSwarmSnapshot(evt) { + const payload = getEnvelopePayload(evt); + const services = payload.services || []; + for (const svc of services) { + if (svc.name) swarmState.services[svc.name] = svc; + } + } + + function mergeSwarmServiceSnapshot(evt) { + const payload = getEnvelopePayload(evt); + const svc = payload.service; + if (svc && svc.name) swarmState.services[svc.name] = svc; + } + function renderOpenClawGrid() { const names = Object.keys(openclawState.instances).sort(); @@ -591,15 +800,14 @@ } function createAgentsState() { + function agentBucket() { + return { sessions: {}, operations: {}, events: [], eventIDs: new Set() }; + } return { - events: [], - eventIDs: new Set(), - stats: { - messages: 0, - tools: 0, - errors: 0, - toolCounts: {}, - }, + agents: { zap: agentBucket(), orb: agentBucket(), sun: agentBucket() }, + stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} }, + dbStats: { messages: 0, tools: 0, errors: 0 }, + timerInterval: null, }; } @@ -616,6 +824,68 @@ }); } + function getAgentBucket(evt) { + const name = getVMName(evt).toLowerCase(); + return agentsState.agents[name] || null; + } + + function processAgentEvent(evt) { + const agent = getAgentBucket(evt); + if (!agent) return; + + const eventType = getEnvelopeType(evt); + const correlation = getEnvelopeCorrelation(evt); + const attrs = getEnvelopeAttributes(evt); + + 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) { + agent.operations['s:' + correlation.span_id] = { + type: 'span', + name: attrs.name || attrs.span_kind || 'unknown', + kind: attrs.span_kind || '', + startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(), + }; + } + if (eventType === 'span.end' && correlation.span_id) { + delete agent.operations['s:' + correlation.span_id]; + } + + if (eventType === 'run.start' && correlation.run_id) { + agent.operations['r:' + correlation.run_id] = { + type: 'run', + name: 'Thinking…', + kind: 'run', + startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(), + }; + } + if (eventType === 'run.end' && correlation.run_id) { + delete agent.operations['r:' + correlation.run_id]; + } + + 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 hasTools = ops.some(op => op.kind === 'tool'); + return hasTools ? ops.filter(op => op.kind === 'tool') : ops; + } + async function renderAgents() { agentsState = createAgentsState(); @@ -623,98 +893,177 @@ -
-
-
-

Loading agent activity...

-
-
-
-
Messages
-
0
-
received and sent
-
-
-
Tool Calls
-
0
-
-
-
Errors
-
0
-
-
-
Top Tools
-
    -
  • No data yet
  • -
-
-
+
+
+
ZAP

Loading...

+
ORB

Loading...

+
SUN

Loading...

`; - 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 ` +
+
+ ${getEventIcon(eventType)} + ${escapeHTML(getEventLabel(eventType))} + ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} +
+ ${getEventBody(evt)} + ${expandHTML} + ${detailHTML} +
`; + }).join('') : '

No recent activity

'; + + return ` +
+
+
+ + ${escapeHTML(name.toUpperCase())} +
+ ${statusText} +
+ ${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 ` -
-
- ${getEventIcon(eventType)} - ${escapeHTML(vmName)} - ${escapeHTML(getEventLabel(eventType))} - ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} -
- ${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 @@
    Infrastructure
    -
    +
    +
    @@ -1062,32 +1313,48 @@ renderDashVMStrip(); + // Render cached data immediately while the API call is in-flight + const cachedSummary = tryParseJSON(localStorage.getItem('agentmon:dash:summary')); + const cachedTS = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window)); + if (cachedSummary) { dashboardState.summary = cachedSummary; renderSummaryCards(); } + if (cachedTS) { dashboardState.timeseries = cachedTS; renderTimeseriesChart(); renderFrameworkBars(); } + try { - const [summaryData, tsData, recentData, snapshots] = await Promise.all([ + const [summaryData, tsData, recentData, snapshots, swarmSnaps, topToolsData] = await Promise.all([ api('/v1/stats/summary'), api('/v1/stats/timeseries?window=1h'), api('/v1/events?limit=20'), api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })), + api('/v1/events?event_type=swarm.snapshot&limit=10').catch(() => ({ events: [] })), + api('/v1/stats/top-tools').catch(() => ({ tools: [] })), ]); if (!isCurrentPath('/')) return; mergeOpenClawEvents(snapshots.events || []); renderDashVMStrip(); + for (const evt of swarmSnaps.events || []) mergeSwarmSnapshot(evt); + renderSwarmStrip_dash(); dashboardState.summary = summaryData; dashboardState.timeseries = tsData; + localStorage.setItem('agentmon:dash:summary', JSON.stringify(summaryData)); + localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(tsData)); renderSummaryCards(); renderTimeseriesChart(); renderFrameworkBars(); + // Seed tool counts from the dedicated top-tools endpoint + for (const t of (topToolsData.tools || [])) { + dashboardState.toolCounts[t.name] = t.count; + } + const events = (recentData.events || []).slice().reverse(); for (const evt of events) { const id = getRecordID(evt); if (id && !dashboardState.recentEventIDs.has(id)) { dashboardState.recentEventIDs.add(id); dashboardState.recentEvents.push(evt); - tallyTool(evt); } } renderDashFeed(); @@ -1112,6 +1379,25 @@ `).join(''); } + function renderSwarmStrip_dash() { + const strip = document.getElementById('dash-swarm-strip'); + if (!strip) return; + const services = Object.values(swarmState.services); + if (services.length === 0) return; + strip.innerHTML = services.map(svc => { + const statusClass = svc.status === 'healthy' ? 'active' + : svc.status === 'degraded' ? 'degraded' : 'inactive'; + const label = svc.status || 'unknown'; + return ` +
    + + ${escapeHTML(svc.name)} + ${escapeHTML(label)} +
    + `; + }).join(''); + } + function handleDashboardWS(msg) { if (msg.type !== 'message') return; @@ -1122,6 +1408,16 @@ renderDashVMStrip(); return; } + if (eventType === 'swarm.snapshot') { + mergeSwarmSnapshot(msg.data); + renderSwarmStrip_dash(); + return; + } + if (eventType === 'swarm.service.snapshot') { + mergeSwarmServiceSnapshot(msg.data); + renderSwarmStrip_dash(); + return; + } if (dashboardState.summary) { if (eventType === 'session.start') dashboardState.summary.active_sessions++; @@ -1200,9 +1496,12 @@ dashboardChart.destroy(); dashboardChart = null; } + const cachedWin = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window)); + if (cachedWin) { dashboardState.timeseries = cachedWin; renderTimeseriesChart(); } const data = await api('/v1/stats/timeseries?window=' + dashboardState.window); if (!isCurrentPath('/')) return; dashboardState.timeseries = data; + localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(data)); renderTimeseriesChart(); } catch (e) { console.error('Failed to load timeseries:', e); diff --git a/cmd/web-ui/static/style.css b/cmd/web-ui/static/style.css index e98c1bf..29fae0a 100644 --- a/cmd/web-ui/static/style.css +++ b/cmd/web-ui/static/style.css @@ -30,6 +30,9 @@ --radius: 6px; --radius-lg: 10px; --radius-xl: 14px; + + --header-bg: rgba(7, 9, 15, 0.82); + --code-text: #7a9ab5; } /* ── Reset ─────────────────────────────────────────────────── */ @@ -62,7 +65,7 @@ header { justify-content: space-between; padding: 0 2rem; height: 54px; - background: rgba(7, 9, 15, 0.82); + background: var(--header-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-bottom: 1px solid var(--border); @@ -301,7 +304,7 @@ main > * { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); - overflow: hidden; + overflow-x: auto; } table { @@ -394,6 +397,15 @@ tr.clickable:hover td:first-child { letter-spacing: 0.02em; } +.model-badge { + font-family: var(--font-mono); + font-size: 0.72rem; + background: var(--surface-2); + color: var(--text-dim); + padding: 2px 6px; + border-radius: 3px; +} + /* ── Load more ─────────────────────────────────────────────── */ .load-more { display: block; @@ -458,7 +470,7 @@ tr.expandable:hover .expand-icon::before { font-size: 0.78rem; white-space: pre-wrap; word-break: break-all; - color: #7a9ab5; + color: var(--code-text); line-height: 1.75; border-top: 1px solid var(--border); } @@ -737,6 +749,22 @@ tr.expandable:hover .expand-icon::before { letter-spacing: 0.06em; } +/* ── Swarm strip ──────────────────────────────────────────── */ +.swarm-strip { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.vm-pill.degraded { + border-color: rgba(251, 191, 36, 0.3); +} + +.vm-pill.degraded .vm-pill-dot { + background: var(--warning); +} + .timeline { display: flex; flex-direction: column; @@ -850,7 +878,7 @@ tr.expandable:hover .expand-icon::before { border-radius: var(--radius); font-family: var(--font-mono); font-size: 0.75rem; - color: #7a9ab5; + color: var(--code-text); white-space: pre-wrap; word-break: break-all; line-height: 1.65; @@ -1068,9 +1096,9 @@ tr.expandable:hover .expand-icon::before { } .summary-card-value { - font-family: var(--font-display); + font-family: var(--font-body); font-size: 2rem; - font-weight: 800; + font-weight: 600; color: var(--text-bright); letter-spacing: -0.02em; line-height: 1; @@ -1300,10 +1328,17 @@ tr.expandable:hover .expand-icon::before { top: -1px; } -.fw-dot.openclaw { background: var(--accent); } -.fw-dot.claude-code { background: var(--success); } -.fw-dot.opencode { background: var(--purple); } -.fw-dot.unknown { background: var(--text-dim); } +.fw-dot.openclaw { background: var(--accent); --fw-glow: var(--accent); } +.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); } +.fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); } +.fw-dot.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); } +.fw-dot.ended { opacity: 0.3; } +.fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; } + +@keyframes fwPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} /* ── Meta tiles ───────────────────────────────────────────── */ .meta-tiles { @@ -1350,3 +1385,338 @@ tr.expandable:hover .expand-icon::before { letter-spacing: 0.08em; margin-bottom: 0.5rem; } + +/* ── Light theme variables ────────────────────────────────── */ +[data-theme="light"] { + --bg: #f5f7fa; + --surface: #ffffff; + --surface-2: #edf0f5; + --card: rgba(255, 255, 255, 0.9); + --border: rgba(200, 210, 225, 0.8); + --border-soft: rgba(200, 210, 225, 0.5); + + --text: #3d4a5c; + --text-dim: #8b9ab0; + --text-bright: #1a2332; + + --accent: #0891b2; + --accent-dim: rgba(8, 145, 178, 0.08); + --accent-glow: rgba(8, 145, 178, 0.15); + + --success: #059669; + --error: #dc2626; + --warning: #d97706; + --purple: #7c3aed; + + --header-bg: rgba(245, 247, 250, 0.92); + --code-text: #4a6580; +} + +[data-theme="light"] body { + background-image: + radial-gradient(ellipse 80% 40% at 50% -20%, rgba(8, 145, 178, 0.04) 0%, transparent 70%), + radial-gradient(circle at 1px 1px, rgba(8, 145, 178, 0.025) 1px, transparent 0); +} + +@media (prefers-color-scheme: light) { + html:not([data-theme]) { + --bg: #f5f7fa; + --surface: #ffffff; + --surface-2: #edf0f5; + --card: rgba(255, 255, 255, 0.9); + --border: rgba(200, 210, 225, 0.8); + --border-soft: rgba(200, 210, 225, 0.5); + + --text: #3d4a5c; + --text-dim: #8b9ab0; + --text-bright: #1a2332; + + --accent: #0891b2; + --accent-dim: rgba(8, 145, 178, 0.08); + --accent-glow: rgba(8, 145, 178, 0.15); + + --success: #059669; + --error: #dc2626; + --warning: #d97706; + --purple: #7c3aed; + + --header-bg: rgba(245, 247, 250, 0.92); + --code-text: #4a6580; + } + + html:not([data-theme]) body { + background-image: + radial-gradient(ellipse 80% 40% at 50% -20%, rgba(8, 145, 178, 0.04) 0%, transparent 70%), + radial-gradient(circle at 1px 1px, rgba(8, 145, 178, 0.025) 1px, transparent 0); + } +} + +/* ── Theme toggle button ──────────────────────────────────── */ +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: transparent; + color: var(--text-dim); + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; + margin-left: 0.75rem; + flex-shrink: 0; +} + +.theme-toggle:hover { + color: var(--text-bright); + border-color: var(--accent); + background: var(--accent-dim); +} + +/* ── Agent lanes ──────────────────────────────────────────── */ +.agents-summary-row { + display: flex; + gap: 1.25rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.agents-summary-stat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.6rem 1rem; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.agents-summary-stat .value { + color: var(--text-bright); + font-weight: 600; +} + +.agent-lanes { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.25rem; +} + +@media (max-width: 900px) { + .agent-lanes { + grid-template-columns: 1fr; + } +} + +.agent-lane { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.agent-lane-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1.125rem; + border-bottom: 1px solid var(--border-soft); +} + +.agent-lane-name { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-display); + font-size: 0.9rem; + font-weight: 700; + color: var(--text-bright); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.agent-lane-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.agent-lane-dot.online { + background: var(--success); + box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); + animation: livePulse 2s ease-in-out infinite; +} + +.agent-lane-dot.offline { + background: var(--text-dim); + opacity: 0.5; +} + +.agent-lane-status { + font-size: 0.7rem; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.agent-lane-status.has-sessions { + color: var(--success); +} + +/* ── Active operations ────────────────────────────────────── */ +.active-ops { + padding: 0.625rem 1.125rem; + display: flex; + flex-direction: column; + gap: 0.375rem; + border-bottom: 1px solid var(--border-soft); +} + +.active-op { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.625rem; + background: var(--accent-dim); + border: 1px solid rgba(34, 211, 238, 0.15); + border-radius: var(--radius); + font-size: 0.75rem; + animation: fadeUp 0.2s ease both; +} + +[data-theme="light"] .active-op { + border-color: rgba(8, 145, 178, 0.2); +} + +.active-op.stale { + opacity: 0.5; +} + +.active-op-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); + animation: livePulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +.active-op.stale .active-op-dot { + background: var(--warning); + box-shadow: none; + animation: none; +} + +.active-op-name { + font-family: var(--font-mono); + color: var(--accent); + font-weight: 500; +} + +.active-op-time { + font-family: var(--font-mono); + color: var(--text-dim); + font-size: 0.7rem; + margin-left: auto; +} + +.active-op-stale { + color: var(--warning); + font-size: 0.65rem; + font-weight: 600; +} + +/* ── Lane event feed ──────────────────────────────────────── */ +.agent-lane-events { + flex: 1; + overflow-y: auto; + max-height: 520px; + padding: 0.625rem; + position: relative; +} + +.agent-lane-events::after { + content: ''; + position: sticky; + bottom: 0; + left: 0; + right: 0; + height: 32px; + background: linear-gradient(to bottom, transparent, var(--surface)); + pointer-events: none; + display: block; +} + +.agent-lane-events .timeline-event { + padding: 0.5rem 0.625rem; + border-radius: var(--radius); + margin-bottom: 0.25rem; + border: 1px solid var(--border-soft); + background: transparent; + font-size: 0.82rem; +} + +.agent-lane-events .timeline-event-header { + margin-bottom: 0.2rem; +} + +.agent-lane-events .timeline-event-time { + font-size: 0.6rem; +} + +.agent-lane-events .empty-state { + padding: 2rem 1rem; + font-size: 0.78rem; +} + +/* ── Mobile layout ────────────────────────────────────────── */ +@media (max-width: 640px) { + main { + padding: 1.25rem 1rem; + } + + header { + padding: 0 1rem; + } + + header nav a { + padding: 0.375rem 0.5rem; + font-size: 0.75rem; + } +} + +@media (max-width: 480px) { + header { + height: auto; + flex-wrap: wrap; + padding: 0.5rem 1rem; + gap: 0; + } + + .header-logo { + flex: 1; + } + + header nav { + width: 100%; + justify-content: flex-start; + border-top: 1px solid var(--border-soft); + padding: 0.375rem 0; + margin-top: 0.25rem; + gap: 0; + } + + header nav a { + padding: 0.3rem 0.5rem; + font-size: 0.73rem; + } +}