diff --git a/cmd/web-ui/main.go b/cmd/web-ui/main.go index a968aa9..817d810 100644 --- a/cmd/web-ui/main.go +++ b/cmd/web-ui/main.go @@ -99,6 +99,15 @@ func main() { staticFS, _ := fs.Sub(staticFiles, "static") fileServer := http.FileServer(http.FS(staticFS)) + mux.HandleFunc("/favicon.svg", func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = "/favicon.svg" + fileServer.ServeHTTP(w, r) + }) + + mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/favicon.svg", http.StatusMovedPermanently) + }) + mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = strings.TrimPrefix(r.URL.Path, "/static") fileServer.ServeHTTP(w, r) diff --git a/cmd/web-ui/static/app.js b/cmd/web-ui/static/app.js index ce81f07..839101b 100644 --- a/cmd/web-ui/static/app.js +++ b/cmd/web-ui/static/app.js @@ -47,12 +47,62 @@ }); } + // Cmd+K hint button + document.getElementById('cmd-k-hint')?.addEventListener('click', openCommandPalette); + + // Global error tracking — persists across page navigations + subscribeWS(function globalErrorTracker(msg) { + if (msg.type !== 'message') return; + if (getEnvelopeType(msg.data) === 'error') { + incrementErrorBadge(); + } + }); + // Keyboard Shortcuts + let _pendingGoto = false; document.addEventListener('keydown', (e) => { - // '/' to focus search - if (e.key === '/' && document.activeElement !== searchInput && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) { + // Ignore when typing in inputs + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) { + if (e.key === 'Escape') document.activeElement.blur(); + return; + } + + // Cmd+K or Ctrl+K — command palette + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); - searchInput.focus(); + if (paletteOpen) closeCommandPalette(); + else openCommandPalette(); + return; + } + + // '/' to focus search + if (e.key === '/' && !paletteOpen) { + e.preventDefault(); + const searchInput = document.getElementById('global-search'); + if (searchInput) searchInput.focus(); + return; + } + + // Escape closes palette + if (e.key === 'Escape' && paletteOpen) { + closeCommandPalette(); + return; + } + + // 'g' prefix for goto shortcuts + if (e.key === 'g' && !_pendingGoto) { + _pendingGoto = true; + setTimeout(() => { _pendingGoto = false; }, 800); + return; + } + + if (_pendingGoto) { + _pendingGoto = false; + if (e.key === 'd') navigate('/'); + else if (e.key === 's') navigate('/sessions'); + else if (e.key === 'a') navigate('/agents'); + else if (e.key === 'i') navigate('/infrastructure'); + return; } }); }); @@ -104,8 +154,173 @@ `; } + + // ── Command Palette ───────────────────────────────────── + let paletteOpen = false; + let paletteSelectedIndex = 0; + + function getCommandPaletteItems(query) { + const items = [ + { label: 'Dashboard', path: '/', icon: '◉', shortcut: 'g d' }, + { label: 'Sessions', path: '/sessions', icon: '▶', shortcut: 'g s' }, + { label: 'Agents', path: '/agents', icon: '◎', shortcut: 'g a' }, + { label: 'Infrastructure', path: '/infrastructure', icon: '⚡', shortcut: 'g i' }, + { label: 'Toggle Theme', action: 'theme', icon: '◐' }, + ]; + + // Add agent items dynamically + if (agentsState && agentsState.agents) { + for (const [key, agent] of Object.entries(agentsState.agents)) { + items.push({ + label: 'Agent: ' + (agent.name || key), + path: '/agents', + action: 'select-agent', + agentKey: key, + icon: isAgentOnline(agent) ? '●' : '○', + }); + } + } + + if (!query) return items; + const q = query.toLowerCase(); + return items.filter(item => item.label.toLowerCase().includes(q)); + } + + function openCommandPalette() { + if (paletteOpen) return; + paletteOpen = true; + paletteSelectedIndex = 0; + + const backdrop = document.createElement('div'); + backdrop.className = 'cmd-palette-backdrop'; + backdrop.id = 'cmd-palette-backdrop'; + backdrop.innerHTML = ` +
+
+ + +
+
+ +
+ `; + + document.body.appendChild(backdrop); + const input = document.getElementById('cmd-palette-input'); + input.focus(); + renderPaletteItems(''); + + input.addEventListener('input', () => { + paletteSelectedIndex = 0; + renderPaletteItems(input.value); + }); + + input.addEventListener('keydown', (e) => { + const items = document.querySelectorAll('.cmd-palette-item'); + if (e.key === 'ArrowDown') { + e.preventDefault(); + paletteSelectedIndex = Math.min(paletteSelectedIndex + 1, items.length - 1); + updatePaletteSelection(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + paletteSelectedIndex = Math.max(paletteSelectedIndex - 1, 0); + updatePaletteSelection(); + } else if (e.key === 'Enter') { + e.preventDefault(); + const selected = items[paletteSelectedIndex]; + if (selected) selected.click(); + } else if (e.key === 'Escape') { + closeCommandPalette(); + } + }); + + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) closeCommandPalette(); + }); + } + + function closeCommandPalette() { + paletteOpen = false; + const backdrop = document.getElementById('cmd-palette-backdrop'); + if (backdrop) backdrop.remove(); + } + + function renderPaletteItems(query) { + const container = document.getElementById('cmd-palette-results'); + if (!container) return; + const items = getCommandPaletteItems(query); + + // If query looks like an ID (8+ hex chars), add a search option + if (query.length >= 8) { + items.unshift({ label: 'Search for ID: ' + query, action: 'search', query, icon: '🔍' }); + } + + container.innerHTML = items.map((item, i) => ` +
+
${item.icon}
+ ${escapeHTML(item.label)} + ${item.shortcut ? `${item.shortcut}` : ''} +
+ `).join(''); + + container.querySelectorAll('.cmd-palette-item').forEach((el, i) => { + el.addEventListener('click', () => executePaletteItem(items[i])); + el.addEventListener('mouseenter', () => { + paletteSelectedIndex = i; + updatePaletteSelection(); + }); + }); + } + + function updatePaletteSelection() { + document.querySelectorAll('.cmd-palette-item').forEach((el, i) => { + el.classList.toggle('selected', i === paletteSelectedIndex); + if (i === paletteSelectedIndex) el.scrollIntoView({ block: 'nearest' }); + }); + } + + function executePaletteItem(item) { + closeCommandPalette(); + if (item.action === 'theme') { + cycleTheme(); + } else if (item.action === 'search') { + handleGlobalSearch(item.query); + } else if (item.action === 'select-agent') { + navigate('/agents'); + setTimeout(() => selectAgent(item.agentKey, 'live'), 100); + } else if (item.path) { + navigate(item.path); + } + } + // ───────────────────────────────────────────────────────── + // ── Error Badge ───────────────────────────────────────── + let _unseenErrors = 0; + + function incrementErrorBadge() { + if (window.location.pathname === '/') return; // On dashboard, don't badge + _unseenErrors++; + const badge = document.getElementById('nav-error-badge'); + if (badge) { + badge.textContent = _unseenErrors > 99 ? '99+' : String(_unseenErrors); + badge.classList.add('visible'); + } + } + + function clearErrorBadge() { + _unseenErrors = 0; + const badge = document.getElementById('nav-error-badge'); + if (badge) { + badge.classList.remove('visible'); + badge.textContent = ''; + } + } + const app = document.getElementById('app'); let ws = null; @@ -116,9 +331,12 @@ let sessionsState = { sessions: [], cursor: null, activeSessionByBackend: {} }; let sessionsUnsubscribe = null; + // ── Session Filter Pills ──────────────────────────────── + let sessionFilterMode = 'all'; let openclawState = { instances: {} }; let openclawUnsubscribe = null; let infraUnsubscribe = null; + let _infraTimerInterval = null; let swarmState = { services: {} }; // keyed by service name let agentsState = createAgentsState(); let agentsUnsubscribe = null; @@ -212,6 +430,10 @@ infraUnsubscribe(); infraUnsubscribe = null; } + if (_infraTimerInterval) { + clearInterval(_infraTimerInterval); + _infraTimerInterval = null; + } if (agentsUnsubscribe) { agentsUnsubscribe(); agentsUnsubscribe = null; @@ -254,23 +476,33 @@ cleanupLiveViews(); renderBreadcrumbs(); - const path = window.location.pathname; - if (path === '/') { - renderDashboard(); - } else if (path === '/sessions') { - renderSessions(); - } else if (path.startsWith('/agents')) { - renderAgents(); - } else if (path.startsWith('/infrastructure')) { - renderInfrastructure(); - } else if (path.startsWith('/sessions/')) { - renderSession(path.split('/sessions/')[1]); - } else if (path.startsWith('/runs/')) { - renderRun(path.split('/runs/')[1]); - } else { - app.innerHTML = '

Page not found

The page you\'re looking for doesn\'t exist.

Go to Dashboard
'; - } - updateActiveNav(); + app.classList.add('transitioning'); + requestAnimationFrame(() => { + setTimeout(() => { + const path = window.location.pathname; + if (path === '/') { + renderDashboard(); + } else if (path === '/sessions') { + renderSessions(); + } else if (path.startsWith('/agents')) { + renderAgents(); + } else if (path.startsWith('/infrastructure')) { + renderInfrastructure(); + } else if (path.startsWith('/sessions/')) { + renderSession(path.split('/sessions/')[1]); + } else if (path.startsWith('/runs/')) { + renderRun(path.split('/runs/')[1]); + } else { + app.innerHTML = '

Page not found

The page you\'re looking for doesn\'t exist.

Go to Dashboard
'; + } + updateActiveNav(); + + // Fade back in + requestAnimationFrame(() => { + app.classList.remove('transitioning'); + }); + }, 80); + }); } function renderBreadcrumbs() { @@ -365,6 +597,58 @@ .replace(/'/g, '''); } + function animateCounter(elementId, newValue) { + const elem = document.getElementById(elementId); + if (!elem) return; + const oldText = elem.textContent; + const newText = String(newValue); + if (oldText === newText) return; + elem.textContent = newText; + elem.classList.remove('bumped'); + void elem.offsetWidth; // force reflow + elem.classList.add('bumped'); + } + + // ── Dashboard Sparklines ──────────────────────────────── + function buildSparklineSVG(values, color) { + if (!values || values.length < 2) return ''; + const max = Math.max(...values, 1); + const w = 100; + const h = 30; + const points = values.map((v, i) => { + const x = (i / (values.length - 1)) * w; + const y = h - (v / max) * h; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + const polyline = points.join(' '); + // Area fill: close the path along the bottom + const areaPath = `M0,${h} L${points.map(p => p).join(' L')} L${w},${h} Z`; + return ` + + + `; + } + + function renderDashSparklines() { + const ts = dashboardState.timeseries; + if (!ts || !ts.series || ts.series.length < 2) return; + const cards = document.querySelectorAll('.summary-card'); + if (cards.length < 4) return; + + const runsData = ts.series.map(b => b.runs || 0); + const toolsData = ts.series.map(b => b.tools || 0); + const errorsData = ts.series.map(b => b.errors || 0); + const totalData = ts.series.map((b, i) => runsData[i] + toolsData[i] + errorsData[i]); + + // Remove existing sparklines + cards.forEach(c => { const s = c.querySelector('.summary-card-sparkline'); if (s) s.remove(); }); + + cards[0].insertAdjacentHTML('beforeend', buildSparklineSVG(totalData, 'var(--accent)')); + cards[1].insertAdjacentHTML('beforeend', buildSparklineSVG(runsData, 'var(--success)')); + cards[2].insertAdjacentHTML('beforeend', buildSparklineSVG(toolsData, 'var(--purple)')); + cards[3].insertAdjacentHTML('beforeend', buildSparklineSVG(errorsData, 'var(--error)')); + } + function relativeTime(ts) { if (!ts) { return '-'; @@ -411,6 +695,41 @@ ).join(''); } + // ── Content-Aware Skeletons ───────────────────────────── + function dashboardSkeleton() { + return ` +
${Array(4).fill('
').join('')}
+
+ `; + } + + function sessionsSkeleton() { + // Returns rows suitable for insertion into a + return Array(8).fill(0).map((_, i) => { + const widths = [['55%','25%'], ['65%','20%'], ['45%','30%'], ['70%','15%'], ['50%','22%'], ['60%','18%'], ['42%','28%'], ['68%','12%']]; + const [w1, w2] = widths[i % widths.length]; + return ` +
+
+
+
+
+ `; + }).join(''); + } + + function agentsSkeleton() { + return `
+ ${Array(4).fill('
').join('')} +
`; + } + + function infrastructureSkeleton() { + return `
+ ${Array(6).fill('
').join('')} +
`; + } + function extractEnvelope(record) { if (record && typeof record === 'object' && record.payload && record.payload.event && record.payload.schema) { return record.payload; @@ -536,11 +855,51 @@ function refreshSessionsTable() { const tbody = document.getElementById('sessions-body'); if (!tbody) return; - const groups = groupSessionsByDate(sessionsState.sessions); + + // Update pill counts based on full unfiltered sessions list + const all = sessionsState.sessions; + const activeCount = all.filter(s => isSessionActive(s)).length; + const endedCount = all.filter(s => !isSessionActive(s)).length; + const erroredCount = all.filter(s => (s._errorCount || 0) > 0).length; + const pillDefs = [ + { filter: 'all', count: all.length }, + { filter: 'active', count: activeCount }, + { filter: 'ended', count: endedCount }, + { filter: 'errored', count: erroredCount }, + ]; + pillDefs.forEach(({ filter, count }) => { + const btn = document.querySelector(`#session-pills [data-filter="${filter}"]`); + if (!btn) return; + let countEl = btn.querySelector('.pill-count'); + if (!countEl) { + countEl = document.createElement('span'); + countEl.className = 'pill-count'; + btn.appendChild(countEl); + } + countEl.textContent = count; + }); + + // Apply filter + let filtered = sessionsState.sessions; + if (sessionFilterMode === 'active') { + filtered = filtered.filter(s => isSessionActive(s)); + } else if (sessionFilterMode === 'ended') { + filtered = filtered.filter(s => !isSessionActive(s)); + } else if (sessionFilterMode === 'errored') { + filtered = filtered.filter(s => (s._errorCount || 0) > 0); + } + + const groups = groupSessionsByDate(filtered); if (groups.length === 0) { tbody.innerHTML = 'No sessions found'; return; } + const allFiltered = groups.flatMap(g => g.items); + const maxDuration = Math.max(...allFiltered.map(s => { + const start = new Date(s.started_at).getTime(); + const end = s.ended_at ? new Date(s.ended_at).getTime() : Date.now(); + return end - start; + }), 1); tbody.innerHTML = groups.map(group => { const rows = group.items.map(s => { const fw = s.framework || 'unknown'; @@ -550,13 +909,19 @@ const dotTitle = dotState === 'active' ? 'Currently active session' : (active ? 'Open session' : 'Session ended'); + const rowClass = active ? 'clickable active-session' : 'clickable'; + const start = new Date(s.started_at).getTime(); + const end = s.ended_at ? new Date(s.ended_at).getTime() : Date.now(); + const duration = end - start; + const barWidth = Math.max(4, (duration / maxDuration) * 80); + const durationBar = ``; return ` - + ${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)} ${escapeHTML(fw)} ${escapeHTML(s.host || '-')} ${s.run_count} - ${escapeHTML(relativeTime(s.started_at))} + ${escapeHTML(relativeTime(s.started_at))}${durationBar} `; }).join(''); return `${escapeHTML(group.label)}${rows}`; @@ -567,6 +932,9 @@ } async function renderSessions() { + // Reset filter mode on each page visit + sessionFilterMode = 'all'; + app.innerHTML = ` +
+ + + + +
@@ -592,12 +966,22 @@ - ${skeletonRows(8, 5)} + ${sessionsSkeleton()}
Time
`; + // Wire up filter pill click handlers + document.querySelectorAll('#session-pills .filter-pill').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#session-pills .filter-pill').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + sessionFilterMode = btn.dataset.filter; + refreshSessionsTable(); + }); + }); + api('/v1/stats/summary').then(data => { const sel = document.getElementById('filter-framework'); if (!sel || !data.by_framework) return; @@ -668,7 +1052,12 @@ const row = tbody.querySelector(`[data-session="${s.session_id}"]`); if (row) { const td = row.cells[4]; - if (td) td.textContent = relativeTime(s.started_at); + if (td) { + // Update only the text node, preserving the duration bar span + const bar = td.querySelector('.session-duration-bar'); + td.textContent = relativeTime(s.started_at); + if (bar) td.appendChild(bar); + } } }); } @@ -722,6 +1111,13 @@ } } + if (eventType === 'error' && sessionId) { + const session = sessionsState.sessions.find(s => s.session_id === sessionId); + if (session) { + session._errorCount = (session._errorCount || 0) + 1; + } + } + refreshSessionsTable(); } @@ -836,7 +1232,7 @@ const correlation = getEnvelopeCorrelation(msg.data); if (correlation?.session_id !== sessionID) return; const eventType = getEnvelopeType(msg.data); - if (!['run.start', 'run.end', 'session.end', 'error'].includes(eventType)) return; + if (!['run.start', 'run.end', 'span.start', 'span.end', 'session.end', 'error'].includes(eventType)) return; clearTimeout(_sessionReloadTimer); _sessionReloadTimer = setTimeout(() => loadSessionData(sessionID), 300); } @@ -1241,7 +1637,7 @@ } async function renderInfrastructure() { - app.innerHTML = '

Loading...

'; + app.innerHTML = `
${infrastructureSkeleton()}
`; infraUnsubscribe = subscribeWS(handleInfraWS); @@ -1364,6 +1760,14 @@ } `; + + // Start freshness timer — update "Updated X ago" text every 10s + if (_infraTimerInterval) clearInterval(_infraTimerInterval); + _infraTimerInterval = setInterval(() => { + document.querySelectorAll('.freshness-timer[data-ts]').forEach(el => { + el.textContent = 'Updated ' + relativeTime(el.dataset.ts); + }); + }, 10000); } function renderVMCard(name) { @@ -1382,7 +1786,7 @@ ${host.state === 'running' ? 'Running' : 'Stopped'} -
Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}
+
Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}
@@ -1434,10 +1838,11 @@ } function serviceCardHeader(svc) { + const uptimeBadge = getUptimeBadge(svc.uptime_sec); return `
-
${escapeHTML(svc.name)}
+
${escapeHTML(svc.name)}${uptimeBadge ? ' ' + uptimeBadge : ''}
${escapeHTML(svc.role || '')}
${escapeHTML(svc.status || 'down')} @@ -1454,6 +1859,15 @@ `; } + // ── Infrastructure Uptime & Freshness ─────────────────── + function getUptimeBadge(uptimeSec) { + if (!uptimeSec) return ''; + const hours = uptimeSec / 3600; + const pct = Math.min(100, (hours / 24) * 100); + const cls = pct >= 99 ? 'good' : pct >= 90 ? 'warn' : 'bad'; + return `${pct.toFixed(0)}% / 24h`; + } + function formatUptime(sec) { if (!sec) return '-'; if (sec < 60) return sec + 's'; @@ -1950,7 +2364,7 @@
-

Loading...

+
${agentsSkeleton()}
`; bindAgentViewToggle(); @@ -2038,6 +2452,31 @@ } } + // ── Agent Lane Sparklines ─────────────────────────────── + 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 `
${buckets.map(b => { + const pct = (b / max * 100).toFixed(0); + return `
`; + }).join('')}
`; + } + function renderAgentLanes() { const contentEl = document.getElementById('agents-content'); if (!contentEl) return; @@ -2108,6 +2547,7 @@ ${escapeHTML(agent.name || key)}
${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}
+ ${buildAgentActivityBars(agent)} ${statusText} @@ -2790,6 +3230,7 @@ } async function renderDashboard() { + clearErrorBadge(); dashboardState = { summary: null, timeseries: null, @@ -2970,7 +3411,7 @@ 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(); renderRightPanel(); } + if (cachedTS) { dashboardState.timeseries = cachedTS; renderTimeseriesChart(); renderDashSparklines(); renderRightPanel(); } try { const [summaryData, tsData, recentData, snapshots, swarmSnaps, topToolsData, topModelsData] = await Promise.all([ @@ -2995,6 +3436,7 @@ localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(tsData)); renderSummaryCards(); renderTimeseriesChart(); + renderDashSparklines(); renderRightPanel(); // Seed tool counts from the dedicated top-tools endpoint @@ -3149,15 +3591,10 @@ const s = dashboardState.summary; if (!s) return; - const el = (id, val) => { - const e = document.getElementById(id); - if (e) e.textContent = String(val); - }; - - el('dash-active', s.active_sessions); - el('dash-runs', s.runs_today); - el('dash-tools', s.tool_calls_today); - el('dash-errors', s.errors_today); + animateCounter('dash-active', s.active_sessions); + animateCounter('dash-runs', s.runs_today); + animateCounter('dash-tools', s.tool_calls_today); + animateCounter('dash-errors', s.errors_today); // Sub-line: framework breakdown for active sessions const fws = Object.keys(s.by_framework || {}); @@ -3172,15 +3609,15 @@ } // Metrics strip - el('dash-tokens-today', formatTokenCount(s.tokens_today || 0)); - el('dash-cost-today', s.cost_today ? formatCost(s.cost_today) : '$0.0000'); - el('dash-avg-duration', s.avg_duration_ms ? formatDuration(s.avg_duration_ms) : '-'); + animateCounter('dash-tokens-today', formatTokenCount(s.tokens_today || 0)); + animateCounter('dash-cost-today', s.cost_today ? formatCost(s.cost_today) : '$0.0000'); + animateCounter('dash-avg-duration', s.avg_duration_ms ? formatDuration(s.avg_duration_ms) : '-'); const errorRateEl = document.getElementById('dash-error-rate'); if (errorRateEl) { const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0); const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0; - errorRateEl.textContent = rate.toFixed(1) + '%'; + animateCounter('dash-error-rate', rate.toFixed(1) + '%'); errorRateEl.classList.toggle('alert', rate > 5); } } @@ -3194,12 +3631,13 @@ } dashboardState.chartCursorIndex = null; const cachedWin = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window)); - if (cachedWin) { dashboardState.timeseries = cachedWin; renderTimeseriesChart(); renderRightPanel(); } + if (cachedWin) { dashboardState.timeseries = cachedWin; renderTimeseriesChart(); renderDashSparklines(); renderRightPanel(); } 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(); + renderDashSparklines(); renderRightPanel(); } catch (e) { console.error('Failed to load timeseries:', e); @@ -3500,6 +3938,7 @@ dashboardState.chartCursorIndex = ts.series.length - 1; renderTimeseriesChart(); + renderDashSparklines(); } function renderRightPanel() { diff --git a/cmd/web-ui/static/favicon.svg b/cmd/web-ui/static/favicon.svg new file mode 100644 index 0000000..8ad1da5 --- /dev/null +++ b/cmd/web-ui/static/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/cmd/web-ui/static/index.html b/cmd/web-ui/static/index.html index fe45396..c144bb6 100644 --- a/cmd/web-ui/static/index.html +++ b/cmd/web-ui/static/index.html @@ -4,6 +4,7 @@ agentmon + @@ -16,11 +17,14 @@ - +
diff --git a/cmd/web-ui/static/style.css b/cmd/web-ui/static/style.css index 31ce311..be741ac 100644 --- a/cmd/web-ui/static/style.css +++ b/cmd/web-ui/static/style.css @@ -150,6 +150,35 @@ header nav a.active::after { border-radius: 1px; } +/* ── Error Notification Badge ─────────────────────────────── */ +nav a { position: relative; } + +.nav-badge { + display: none; + position: absolute; + top: -2px; + right: -8px; + min-width: 16px; + height: 16px; + padding: 0 4px; + background: var(--error); + color: #fff; + font-size: 0.6rem; + font-weight: 600; + border-radius: 8px; + text-align: center; + line-height: 16px; + animation: badgePop 300ms ease; +} + +.nav-badge.visible { display: block; } + +@keyframes badgePop { + 0% { transform: scale(0); } + 60% { transform: scale(1.3); } + 100% { transform: scale(1); } +} + /* ── Header Search ────────────────────────────────────────── */ .header-search { position: relative; @@ -458,6 +487,43 @@ tr.tr-error .id-cell { 100% { transform: translateX(100%); } } +/* ── Content-Aware Skeletons ──────────────────────────────── */ +.skeleton-card { + background: var(--card); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + padding: 1.25rem; + min-height: 120px; +} + +.skeleton-card .skeleton-line { margin-bottom: 0.5rem; } + +.skeleton-summary-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.skeleton-timeline-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-radius: var(--radius); + background: var(--card); + margin-bottom: 0.5rem; +} + +.skeleton-circle { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--surface-2); + animation: skeletonPulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} + /* ── Status badges ─────────────────────────────────────────── */ .status-badge { display: inline-flex; @@ -570,6 +636,141 @@ tr:hover .copy-btn, .toast-error { border-left: 3px solid var(--error); } .toast-info { border-left: 3px solid var(--accent); } +/* ── Command Palette ──────────────────────────────────────── */ +.cmd-palette-backdrop { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + padding-top: 20vh; + animation: fadeIn 150ms ease; +} + +.cmd-palette { + width: min(520px, 90vw); + max-height: 400px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; + animation: slideDown 150ms ease; +} + +.cmd-palette-input-wrap { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + gap: 0.5rem; +} + +.cmd-palette-input-wrap svg { color: var(--text-dim); flex-shrink: 0; } + +.cmd-palette-input { + flex: 1; + background: none; + border: none; + color: var(--text-bright); + font-family: var(--font-body); + font-size: 0.95rem; + outline: none; +} + +.cmd-palette-input::placeholder { color: var(--text-dim); } + +.cmd-palette-results { + overflow-y: auto; + padding: 0.5rem; + flex: 1; +} + +.cmd-palette-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + cursor: pointer; + transition: background 100ms; +} + +.cmd-palette-item:hover, +.cmd-palette-item.selected { + background: var(--accent-dim); +} + +.cmd-palette-item.selected { + outline: 1px solid var(--accent-glow); +} + +.cmd-palette-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + background: var(--surface-2); + color: var(--text-dim); + font-size: 0.8rem; + flex-shrink: 0; +} + +.cmd-palette-item.selected .cmd-palette-icon, +.cmd-palette-item:hover .cmd-palette-icon { + color: var(--accent); + background: var(--accent-dim); +} + +.cmd-palette-label { + flex: 1; + font-size: 0.85rem; + color: var(--text); +} + +.cmd-palette-label strong { + color: var(--text-bright); + font-weight: 500; +} + +.cmd-palette-kbd { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-dim); + background: var(--surface-2); + padding: 0.15rem 0.4rem; + border-radius: 3px; + border: 1px solid var(--border-soft); +} + +.cmd-palette-footer { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + border-top: 1px solid var(--border-soft); + font-size: 0.7rem; + color: var(--text-dim); +} + +.cmd-palette-footer kbd { + font-family: var(--font-mono); + font-size: 0.65rem; + background: var(--surface-2); + padding: 0.1rem 0.3rem; + border-radius: 2px; + border: 1px solid var(--border-soft); +} + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } + /* ── Load more ─────────────────────────────────────────────── */ .load-more { display: block; @@ -752,6 +953,8 @@ tr.expandable:hover .expand-icon::before { .vm-card:hover { border-color: rgba(34, 211, 238, 0.18); transform: translateY(-2px); + transition: transform 200ms ease, box-shadow 200ms ease; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); } .vm-card:hover::before { opacity: 1; } @@ -1299,6 +1502,8 @@ tr.expandable:hover .expand-icon::before { border-radius: var(--radius-lg); padding: 1.125rem 1.25rem; transition: border-color 0.2s, border-left-color 0.2s; + position: relative; + overflow: hidden; } .summary-card:hover { @@ -1336,6 +1541,21 @@ tr.expandable:hover .expand-icon::before { color: var(--error); } +/* ── Counter Animations ───────────────────────────────────── */ +.summary-card-value.bumped { + animation: counterBump 400ms ease; +} + +@keyframes counterBump { + 0% { transform: scale(1); } + 30% { transform: scale(1.15); color: var(--card-accent, var(--accent)); } + 100% { transform: scale(1); } +} + +.metric-pill-value.bumped { + animation: counterBump 400ms ease; +} + .summary-card-sub { font-size: 0.72rem; color: var(--text-dim); @@ -1343,6 +1563,17 @@ tr.expandable:hover .expand-icon::before { font-family: var(--font-mono); } +/* ── Dashboard Sparklines ─────────────────────────────────── */ +.summary-card-sparkline { + position: absolute; + bottom: 0; + right: 0; + width: 60%; + height: 40px; + opacity: 0.3; + pointer-events: none; +} + .charts-row { display: grid; grid-template-columns: 1fr 320px; @@ -1998,6 +2229,12 @@ tr.expandable:hover .expand-icon::before { cursor: pointer; } +.agent-lane:hover { + transform: translateY(-2px); + transition: transform 200ms ease, box-shadow 200ms ease; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); +} + .agent-lane-header { display: flex; align-items: center; @@ -2025,6 +2262,23 @@ tr.expandable:hover .expand-icon::before { color: var(--text-dim); } +/* ── Agent Lane Sparklines ────────────────────────────────── */ +.agent-lane-sparkline { + display: flex; + align-items: flex-end; + gap: 1px; + height: 20px; + margin-top: 0.25rem; +} + +.agent-lane-sparkline-bar { + flex: 1; + background: var(--accent); + border-radius: 1px 1px 0 0; + opacity: 0.5; + min-height: 1px; +} + .agent-lane-dot { width: 7px; height: 7px; @@ -2491,6 +2745,9 @@ tr.expandable:hover .expand-icon::before { .service-card:hover { border-color: rgba(34, 211, 238, 0.15); + transform: translateY(-2px); + transition: transform 200ms ease, box-shadow 200ms ease; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); } .service-card-header { @@ -2650,6 +2907,65 @@ tr.session-date-group:first-child > td { padding-top: 0.5rem; } +/* ── Session Filter Pills ─────────────────────────────────── */ +.filter-pills { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.filter-pill { + padding: 0.35rem 0.85rem; + border: 1px solid var(--border); + border-radius: 20px; + background: transparent; + color: var(--text-dim); + font-family: var(--font-body); + font-size: 0.78rem; + cursor: pointer; + transition: all 150ms; +} + +.filter-pill:hover { + border-color: var(--accent-glow); + color: var(--text); +} + +.filter-pill.active { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} + +.filter-pill .pill-count { + margin-left: 0.35rem; + font-family: var(--font-mono); + font-size: 0.7rem; + opacity: 0.7; +} + +/* ── Session Duration Bars ────────────────────────────────── */ +tr.clickable.active-session td { + background: var(--accent-dim); +} + +tr.clickable.active-session td:first-child { + border-left: 2px solid var(--accent); + padding-left: calc(1.25rem - 2px); +} + +.session-duration-bar { + display: inline-block; + height: 4px; + background: var(--accent); + border-radius: 2px; + min-width: 4px; + max-width: 80px; + opacity: 0.6; + margin-left: 0.5rem; + vertical-align: middle; +} + /* ── Span kind badge ──────────────────────────────────────── */ .span-kind-badge { display: inline-flex; @@ -3315,6 +3631,26 @@ tr.session-date-group:first-child > td { margin-bottom: 1.5rem; } +/* ── Infrastructure Uptime & Freshness ────────────────────────────────── */ +.uptime-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 10px; + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 500; +} + +.uptime-badge.good { background: rgba(52, 211, 153, 0.15); color: var(--success); } +.uptime-badge.warn { background: rgba(251, 191, 36, 0.15); color: var(--warning); } +.uptime-badge.bad { background: rgba(248, 113, 113, 0.15); color: var(--error); } + +.freshness-timer { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-dim); +} + /* ── Skeleton loading ─────────────────────────────────── */ .skeleton-line { height: 0.85rem; @@ -3350,3 +3686,42 @@ tr.run-span-row[tabindex="0"]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + +/* ── Page Transitions ─────────────────────────────────────── */ +#app { + transition: opacity 120ms ease, transform 120ms ease; +} + +#app.transitioning { + opacity: 0; + transform: translateY(4px); +} + +/* ── Polish: Cmd+K Hint ──────────────────────────────────── */ +.cmd-k-hint { + display: flex; + align-items: center; + background: var(--surface-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 0.2rem 0.5rem; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.7rem; + cursor: pointer; + transition: all 150ms; +} + +.cmd-k-hint:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* ── Polish: Focus Rings ─────────────────────────────────── */ +a:focus-visible, +button:focus-visible, +tr.clickable:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius); +} diff --git a/docs/plans/2026-03-28-ui-ux-improvements-design.md b/docs/plans/2026-03-28-ui-ux-improvements-design.md new file mode 100644 index 0000000..e5ba351 --- /dev/null +++ b/docs/plans/2026-03-28-ui-ux-improvements-design.md @@ -0,0 +1,1168 @@ +# Web UI/UX Improvements — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Holistic UI/UX improvement pass across all pages — better navigation, richer data display, improved micro-interactions, and new features that leverage existing API data. + +**Architecture:** Pure frontend changes to app.js and style.css. No backend modifications needed — all improvements use existing query-api endpoints and WebSocket data. The app is vanilla JS (no framework, no build tools), so all changes are direct edits. + +**Tech Stack:** Vanilla JS, CSS3 custom properties, uPlot charting, WebSocket real-time. + +--- + +## Summary of Changes + +| Area | Improvement | Impact | +|------|-------------|--------| +| Navigation | Command palette (Cmd+K) | High — fast access to any page/session/agent | +| Navigation | Keyboard shortcuts (g+d, g+s, g+a, g+i) | Medium — power user efficiency | +| Dashboard | Sparkline mini-charts on summary cards | High — at-a-glance trend visibility | +| Dashboard | Animated counter transitions | Medium — polished feel on live updates | +| Dashboard | Error pulse effect when errors spike | Medium — draws attention to problems | +| Sessions | Status filter pills (All/Active/Ended/Errored) | High — fast filtering without date pickers | +| Sessions | Duration column with relative bar | Medium — visual session comparison | +| Sessions | Active session highlight strip | Medium — immediately see what's running | +| Agents | Activity sparkline per agent lane | High — history at a glance per agent | +| Agents | Idle timer showing "last active X ago" | Medium — know when agents went quiet | +| Infrastructure | Uptime percentage badges | Medium — quick health assessment | +| Infrastructure | Last-check countdown timers | Low — shows data freshness | +| Cross-cutting | Page transition animations (crossfade) | Medium — polished navigation feel | +| Cross-cutting | Error notification badge in header nav | High — always-visible error awareness | +| Cross-cutting | Auto-refresh relative timestamps | Low — already partially done, needs consistency | +| Cross-cutting | Better skeleton loading screens | Low — content-aware shapes vs generic lines | +| Cross-cutting | Sticky section headers | Low — better scroll context | + +--- + +## Task 1: Command Palette (Cmd+K) + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (add ~120 lines near keyboard shortcut handler, lines 50-57) +- Modify: `cmd/web-ui/static/style.css` (add ~80 lines for palette styles) + +A floating search/navigation overlay triggered by Cmd+K (or Ctrl+K). Shows a filterable list of quick actions: +- Navigate to pages (Dashboard, Sessions, Agents, Infrastructure) +- Search sessions/runs by ID (reuses existing `handleGlobalSearch`) +- Jump to specific agent by name +- Toggle theme + +**Step 1: Add palette HTML and styles** + +In `style.css`, add after the toast notification styles (~line 571): + +```css +/* ── Command Palette ──────────────────────────────────────── */ +.cmd-palette-backdrop { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + padding-top: 20vh; + animation: fadeIn 150ms ease; +} + +.cmd-palette { + width: min(520px, 90vw); + max-height: 400px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; + animation: slideDown 150ms ease; +} + +.cmd-palette-input-wrap { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + gap: 0.5rem; +} + +.cmd-palette-input-wrap svg { color: var(--text-dim); flex-shrink: 0; } + +.cmd-palette-input { + flex: 1; + background: none; + border: none; + color: var(--text-bright); + font-family: var(--font-body); + font-size: 0.95rem; + outline: none; +} + +.cmd-palette-input::placeholder { color: var(--text-dim); } + +.cmd-palette-results { + overflow-y: auto; + padding: 0.5rem; + flex: 1; +} + +.cmd-palette-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + cursor: pointer; + transition: background 100ms; +} + +.cmd-palette-item:hover, +.cmd-palette-item.selected { + background: var(--accent-dim); +} + +.cmd-palette-item.selected { + outline: 1px solid var(--accent-glow); +} + +.cmd-palette-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + background: var(--surface-2); + color: var(--text-dim); + font-size: 0.8rem; + flex-shrink: 0; +} + +.cmd-palette-item.selected .cmd-palette-icon, +.cmd-palette-item:hover .cmd-palette-icon { + color: var(--accent); + background: var(--accent-dim); +} + +.cmd-palette-label { + flex: 1; + font-size: 0.85rem; + color: var(--text); +} + +.cmd-palette-label strong { + color: var(--text-bright); + font-weight: 500; +} + +.cmd-palette-kbd { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-dim); + background: var(--surface-2); + padding: 0.15rem 0.4rem; + border-radius: 3px; + border: 1px solid var(--border-soft); +} + +.cmd-palette-footer { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + border-top: 1px solid var(--border-soft); + font-size: 0.7rem; + color: var(--text-dim); +} + +.cmd-palette-footer kbd { + font-family: var(--font-mono); + font-size: 0.65rem; + background: var(--surface-2); + padding: 0.1rem 0.3rem; + border-radius: 2px; + border: 1px solid var(--border-soft); +} + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } +``` + +**Step 2: Add palette JS logic** + +In `app.js`, add command palette logic after the `copyToClipboard`/`renderCopyButton` functions (~line 107). The palette should: + +- Build a static list of page navigation items +- Build dynamic items from `agentsState.agents` keys +- Filter items on keystroke +- Navigate up/down with arrow keys, select with Enter, close with Escape +- Use `navigate()` for page transitions + +```javascript +// ── Command Palette ───────────────────────────────────── +let paletteOpen = false; +let paletteSelectedIndex = 0; + +function getCommandPaletteItems(query) { + const items = [ + { label: 'Dashboard', path: '/', icon: '◉', shortcut: 'g d' }, + { label: 'Sessions', path: '/sessions', icon: '▶', shortcut: 'g s' }, + { label: 'Agents', path: '/agents', icon: '◎', shortcut: 'g a' }, + { label: 'Infrastructure', path: '/infrastructure', icon: '⚡', shortcut: 'g i' }, + { label: 'Toggle Theme', action: 'theme', icon: '◐' }, + ]; + + // Add agent items dynamically + if (agentsState && agentsState.agents) { + for (const [key, agent] of Object.entries(agentsState.agents)) { + items.push({ + label: 'Agent: ' + (agent.name || key), + path: '/agents', + action: 'select-agent', + agentKey: key, + icon: isAgentOnline(agent) ? '●' : '○', + }); + } + } + + if (!query) return items; + const q = query.toLowerCase(); + return items.filter(item => item.label.toLowerCase().includes(q)); +} + +function openCommandPalette() { + if (paletteOpen) return; + paletteOpen = true; + paletteSelectedIndex = 0; + + const backdrop = document.createElement('div'); + backdrop.className = 'cmd-palette-backdrop'; + backdrop.id = 'cmd-palette-backdrop'; + backdrop.innerHTML = ` +
+
+ + +
+
+ +
+ `; + + document.body.appendChild(backdrop); + const input = document.getElementById('cmd-palette-input'); + input.focus(); + renderPaletteItems(''); + + input.addEventListener('input', () => { + paletteSelectedIndex = 0; + renderPaletteItems(input.value); + }); + + input.addEventListener('keydown', (e) => { + const items = document.querySelectorAll('.cmd-palette-item'); + if (e.key === 'ArrowDown') { + e.preventDefault(); + paletteSelectedIndex = Math.min(paletteSelectedIndex + 1, items.length - 1); + updatePaletteSelection(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + paletteSelectedIndex = Math.max(paletteSelectedIndex - 1, 0); + updatePaletteSelection(); + } else if (e.key === 'Enter') { + e.preventDefault(); + const selected = items[paletteSelectedIndex]; + if (selected) selected.click(); + } else if (e.key === 'Escape') { + closeCommandPalette(); + } + }); + + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) closeCommandPalette(); + }); +} + +function closeCommandPalette() { + paletteOpen = false; + const backdrop = document.getElementById('cmd-palette-backdrop'); + if (backdrop) backdrop.remove(); +} + +function renderPaletteItems(query) { + const container = document.getElementById('cmd-palette-results'); + if (!container) return; + const items = getCommandPaletteItems(query); + + // If query looks like an ID (8+ hex chars), add a search option + if (query.length >= 8) { + items.unshift({ label: 'Search for ID: ' + query, action: 'search', query, icon: '🔍' }); + } + + container.innerHTML = items.map((item, i) => ` +
+
${item.icon}
+ ${escapeHTML(item.label)} + ${item.shortcut ? `${item.shortcut}` : ''} +
+ `).join(''); + + container.querySelectorAll('.cmd-palette-item').forEach((el, i) => { + el.addEventListener('click', () => executePaletteItem(items[i])); + el.addEventListener('mouseenter', () => { + paletteSelectedIndex = i; + updatePaletteSelection(); + }); + }); +} + +function updatePaletteSelection() { + document.querySelectorAll('.cmd-palette-item').forEach((el, i) => { + el.classList.toggle('selected', i === paletteSelectedIndex); + if (i === paletteSelectedIndex) el.scrollIntoView({ block: 'nearest' }); + }); +} + +function executePaletteItem(item) { + closeCommandPalette(); + if (item.action === 'theme') { + cycleTheme(); + } else if (item.action === 'search') { + handleGlobalSearch(item.query); + } else if (item.action === 'select-agent') { + navigate('/agents'); + setTimeout(() => selectAgent(item.agentKey, 'live'), 100); + } else if (item.path) { + navigate(item.path); + } +} +``` + +**Step 3: Wire Cmd+K and "g" key shortcuts into the global keydown handler** + +Replace the existing keyboard shortcut block (lines 50-57) with: + +```javascript +// Keyboard Shortcuts +let _pendingGoto = false; +document.addEventListener('keydown', (e) => { + // Ignore when typing in inputs + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) { + if (e.key === 'Escape') document.activeElement.blur(); + return; + } + + // Cmd+K or Ctrl+K — command palette + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + if (paletteOpen) closeCommandPalette(); + else openCommandPalette(); + return; + } + + // '/' to focus search + if (e.key === '/' && !paletteOpen) { + e.preventDefault(); + const searchInput = document.getElementById('global-search'); + if (searchInput) searchInput.focus(); + return; + } + + // Escape closes palette + if (e.key === 'Escape' && paletteOpen) { + closeCommandPalette(); + return; + } + + // 'g' prefix for goto shortcuts + if (e.key === 'g' && !_pendingGoto) { + _pendingGoto = true; + setTimeout(() => { _pendingGoto = false; }, 800); + return; + } + + if (_pendingGoto) { + _pendingGoto = false; + if (e.key === 'd') navigate('/'); + else if (e.key === 's') navigate('/sessions'); + else if (e.key === 'a') navigate('/agents'); + else if (e.key === 'i') navigate('/infrastructure'); + return; + } +}); +``` + +**Step 4: Commit** + +```bash +git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): add command palette (Cmd+K) and goto keyboard shortcuts" +``` + +--- + +## Task 2: Animated Counter Transitions on Dashboard + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (add ~30 lines, modify `renderSummaryCards`) +- Modify: `cmd/web-ui/static/style.css` (add ~10 lines) + +When dashboard counters update via WebSocket, animate the number change with a brief scale-up + color flash instead of jumping instantly. + +**Step 1: Add counter animation CSS** + +In `style.css`, add after the summary card styles: + +```css +.summary-card-value.bumped { + animation: counterBump 400ms ease; +} + +@keyframes counterBump { + 0% { transform: scale(1); } + 30% { transform: scale(1.15); color: var(--card-accent, var(--accent)); } + 100% { transform: scale(1); } +} + +.metric-pill-value.bumped { + animation: counterBump 400ms ease; +} +``` + +**Step 2: Add animateCounter utility in app.js** + +```javascript +function animateCounter(elementId, newValue) { + const el = document.getElementById(elementId); + if (!el) return; + const oldText = el.textContent; + const newText = String(newValue); + if (oldText === newText) return; + el.textContent = newText; + el.classList.remove('bumped'); + void el.offsetWidth; // force reflow + el.classList.add('bumped'); +} +``` + +**Step 3: Update renderSummaryCards to use animateCounter** + +Replace the plain `el(id, val)` calls with `animateCounter` for the four main counters and the metrics strip values. + +**Step 4: Commit** + +```bash +git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): animated counter transitions on dashboard updates" +``` + +--- + +## Task 3: Error Notification Badge in Header + +**Files:** +- Modify: `cmd/web-ui/static/index.html` (add badge element to nav) +- Modify: `cmd/web-ui/static/app.js` (track error count, update badge) +- Modify: `cmd/web-ui/static/style.css` (badge styles) + +A small red dot/count badge appears on the Dashboard nav link when errors occur. Clears when user visits dashboard. + +**Step 1: Add badge element to header nav in index.html** + +Modify the Dashboard nav link to include a badge span: + +```html +Dashboard +``` + +**Step 2: Add badge CSS** + +```css +nav a { position: relative; } + +.nav-badge { + display: none; + position: absolute; + top: -2px; + right: -8px; + min-width: 16px; + height: 16px; + padding: 0 4px; + background: var(--error); + color: #fff; + font-size: 0.6rem; + font-weight: 600; + border-radius: 8px; + text-align: center; + line-height: 16px; + animation: badgePop 300ms ease; +} + +.nav-badge.visible { display: block; } + +@keyframes badgePop { + 0% { transform: scale(0); } + 60% { transform: scale(1.3); } + 100% { transform: scale(1); } +} +``` + +**Step 3: Add badge tracking logic in app.js** + +```javascript +let _unseenErrors = 0; + +function incrementErrorBadge() { + if (window.location.pathname === '/') return; // On dashboard, don't badge + _unseenErrors++; + const badge = document.getElementById('nav-error-badge'); + if (badge) { + badge.textContent = _unseenErrors > 99 ? '99+' : String(_unseenErrors); + badge.classList.add('visible'); + } +} + +function clearErrorBadge() { + _unseenErrors = 0; + const badge = document.getElementById('nav-error-badge'); + if (badge) { + badge.classList.remove('visible'); + badge.textContent = ''; + } +} +``` + +Call `incrementErrorBadge()` in the global WS handler when an `error` event arrives, and call `clearErrorBadge()` at the top of `renderDashboard()`. + +**Step 4: Commit** + +```bash +git add cmd/web-ui/static/index.html cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): error notification badge in header nav" +``` + +--- + +## Task 4: Session Status Filter Pills + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (add filter UI and logic in `renderSessions`, ~40 lines) +- Modify: `cmd/web-ui/static/style.css` (pill toggle styles, ~30 lines) + +Add quick-filter pills above the sessions table: **All** | **Active** | **Ended** | **With Errors**. These filter client-side against the already-loaded sessions list. + +**Step 1: Add filter pill CSS** + +```css +.filter-pills { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.filter-pill { + padding: 0.35rem 0.85rem; + border: 1px solid var(--border); + border-radius: 20px; + background: transparent; + color: var(--text-dim); + font-family: var(--font-body); + font-size: 0.78rem; + cursor: pointer; + transition: all 150ms; +} + +.filter-pill:hover { + border-color: var(--accent-glow); + color: var(--text); +} + +.filter-pill.active { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} + +.filter-pill .pill-count { + margin-left: 0.35rem; + font-family: var(--font-mono); + font-size: 0.7rem; + opacity: 0.7; +} +``` + +**Step 2: Add filter pills HTML in renderSessions** + +Insert after the `.page-header` div and before `.filters`: + +```javascript +const pillsHTML = ` +
+ + + + +
+`; +``` + +**Step 3: Add pill click handlers and client-side filtering** + +```javascript +let sessionFilterMode = 'all'; + +function applySessionFilter() { + const filtered = sessionsState.sessions.filter(s => { + if (sessionFilterMode === 'active') return isSessionActive(s); + if (sessionFilterMode === 'ended') return !isSessionActive(s); + if (sessionFilterMode === 'errored') return s.has_errors; + return true; + }); + // Re-render table with filtered list + renderFilteredSessionsTable(filtered); +} +``` + +**Step 4: Wire pill buttons** + +```javascript +document.querySelectorAll('#session-pills .filter-pill').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#session-pills .filter-pill').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + sessionFilterMode = btn.dataset.filter; + applySessionFilter(); + }); +}); +``` + +**Step 5: Commit** + +```bash +git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): session status filter pills (all/active/ended/errored)" +``` + +--- + +## Task 5: Dashboard Sparklines on Summary Cards + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (add sparkline renderer, ~50 lines) +- Modify: `cmd/web-ui/static/style.css` (sparkline styles, ~20 lines) + +Add tiny inline SVG sparkline charts inside each of the four summary cards, showing the trend from the timeseries data. Uses the existing timeseries buckets — no new API calls. + +**Step 1: Add sparkline CSS** + +```css +.summary-card-sparkline { + position: absolute; + bottom: 0; + right: 0; + width: 60%; + height: 40px; + opacity: 0.3; + pointer-events: none; +} + +.summary-card { position: relative; overflow: hidden; } +``` + +**Step 2: Add sparkline SVG builder** + +```javascript +function buildSparklineSVG(values, color) { + if (!values || values.length < 2) return ''; + const max = Math.max(...values, 1); + const w = 100; + const h = 30; + const points = values.map((v, i) => { + const x = (i / (values.length - 1)) * w; + const y = h - (v / max) * h; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + const polyline = points.join(' '); + // Area fill: close the path along the bottom + const areaPath = `M0,${h} L${points.map(p => p).join(' L')} L${w},${h} Z`; + return ` + + + `; +} +``` + +**Step 3: Inject sparklines into summary cards after timeseries loads** + +After `renderSummaryCards()` is called with timeseries data, extract the relevant series and append SVG to each card: + +```javascript +function renderDashSparklines() { + const ts = dashboardState.timeseries; + if (!ts || !ts.series || ts.series.length < 2) return; + const cards = document.querySelectorAll('.summary-card'); + if (cards.length < 4) return; + + const runsData = ts.series.map(b => b.runs || 0); + const toolsData = ts.series.map(b => b.tools || 0); + const errorsData = ts.series.map(b => b.errors || 0); + const totalData = ts.series.map((b, i) => runsData[i] + toolsData[i] + errorsData[i]); + + // Remove existing sparklines + cards.forEach(c => { const s = c.querySelector('.summary-card-sparkline'); if (s) s.remove(); }); + + cards[0].insertAdjacentHTML('beforeend', buildSparklineSVG(totalData, 'var(--accent)')); + cards[1].insertAdjacentHTML('beforeend', buildSparklineSVG(runsData, 'var(--success)')); + cards[2].insertAdjacentHTML('beforeend', buildSparklineSVG(toolsData, 'var(--purple)')); + cards[3].insertAdjacentHTML('beforeend', buildSparklineSVG(errorsData, 'var(--error)')); +} +``` + +Call `renderDashSparklines()` after `renderTimeseriesChart()` in both the initial load and `loadTimeseries()`. + +**Step 4: Commit** + +```bash +git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): sparkline mini-charts on dashboard summary cards" +``` + +--- + +## Task 6: Agent Lane Activity Sparklines + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (add per-agent sparkline in lane render, ~30 lines) +- Modify: `cmd/web-ui/static/style.css` (~15 lines) + +Add a small activity sparkline bar in each agent lane header showing event frequency over recent time (computed from the agent's `events` array). + +**Step 1: Add lane sparkline CSS** + +```css +.agent-lane-sparkline { + display: flex; + align-items: flex-end; + gap: 1px; + height: 20px; + margin-top: 0.25rem; +} + +.agent-lane-sparkline-bar { + flex: 1; + background: var(--accent); + border-radius: 1px 1px 0 0; + opacity: 0.5; + min-height: 1px; +} +``` + +**Step 2: Build agent activity histogram** + +```javascript +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 `
${buckets.map(b => { + const pct = (b / max * 100).toFixed(0); + return `
`; + }).join('')}
`; +} +``` + +**Step 3: Insert into `renderAgentLanes`** + +Add the sparkline HTML after the `agent-lane-meta` div in the lane header. + +**Step 4: Commit** + +```bash +git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): activity sparkline bars in agent lane headers" +``` + +--- + +## Task 7: Page Transition Animations + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (wrap `route()` with transition, ~15 lines) +- Modify: `cmd/web-ui/static/style.css` (transition styles, ~15 lines) + +Add a subtle crossfade when navigating between pages. The `#app` element fades out briefly, content replaces, then fades in. + +**Step 1: Add transition CSS** + +```css +#app { + transition: opacity 120ms ease; +} + +#app.transitioning { + opacity: 0; + transform: translateY(4px); +} +``` + +**Step 2: Wrap route() with transition** + +```javascript +function route() { + cleanupLiveViews(); + renderBreadcrumbs(); + + app.classList.add('transitioning'); + requestAnimationFrame(() => { + // After one frame (opacity transition starts), swap content + setTimeout(() => { + const path = window.location.pathname; + // ... existing routing logic ... + updateActiveNav(); + + // Fade back in + requestAnimationFrame(() => { + app.classList.remove('transitioning'); + }); + }, 80); // Short delay for the fade-out + }); +} +``` + +**Step 3: Commit** + +```bash +git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): subtle crossfade page transitions" +``` + +--- + +## Task 8: Infrastructure Uptime Badges & Freshness Timers + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (add uptime percentage calculation, freshness timer, ~30 lines) +- Modify: `cmd/web-ui/static/style.css` (~20 lines) + +Add uptime percentage badges to service cards and "last checked X ago" timers that count up in real-time. + +**Step 1: Add uptime badge CSS** + +```css +.uptime-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 10px; + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 500; +} + +.uptime-badge.good { background: rgba(52, 211, 153, 0.15); color: var(--success); } +.uptime-badge.warn { background: rgba(251, 191, 36, 0.15); color: var(--warning); } +.uptime-badge.bad { background: rgba(248, 113, 113, 0.15); color: var(--error); } + +.freshness-timer { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-dim); +} +``` + +**Step 2: Calculate uptime from uptime_sec and add to service card headers** + +If `svc.uptime_sec` is available, compute a percentage relative to a 24h window and display as a small colored badge next to the service name: + +```javascript +function getUptimeBadge(uptimeSec) { + if (!uptimeSec) return ''; + const hours = uptimeSec / 3600; + const pct = Math.min(100, (hours / 24) * 100); + const cls = pct >= 99 ? 'good' : pct >= 90 ? 'warn' : 'bad'; + return `${pct.toFixed(0)}% / 24h`; +} +``` + +**Step 3: Add freshness timers to VM cards** + +Add a `data-updated-at` attribute to the "Updated X ago" text, and refresh it with `setInterval` on the infrastructure page: + +```javascript +let _infraTimerInterval = null; + +// In renderInfrastructure, after rendering: +_infraTimerInterval = setInterval(() => { + document.querySelectorAll('.freshness-timer[data-ts]').forEach(el => { + el.textContent = 'Updated ' + relativeTime(el.dataset.ts); + }); +}, 10000); +``` + +**Step 4: Commit** + +```bash +git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): uptime badges and freshness timers on infrastructure page" +``` + +--- + +## Task 9: Better Skeleton Loading Screens + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (add content-aware skeletons per page, ~40 lines) +- Modify: `cmd/web-ui/static/style.css` (~25 lines) + +Replace generic skeleton rows with page-specific loading placeholders that match the shape of real content (card grids, timeline items, lane cards). + +**Step 1: Add skeleton variant CSS** + +```css +.skeleton-card { + background: var(--card); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + padding: 1.25rem; + min-height: 120px; +} + +.skeleton-card .skeleton-line { margin-bottom: 0.5rem; } + +.skeleton-summary-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.skeleton-timeline-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-radius: var(--radius); + background: var(--card); + margin-bottom: 0.5rem; +} + +.skeleton-circle { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--surface-2); + animation: skeletonPulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} +``` + +**Step 2: Add page-specific skeleton builders** + +```javascript +function dashboardSkeleton() { + return ` +
${Array(4).fill('
').join('')}
+
+ `; +} + +function sessionsSkeleton() { + return Array(6).fill(` +
+
+
+
+ `).join(''); +} +``` + +**Step 3: Use these in the initial renders instead of `skeletonRows()`** + +**Step 4: Commit** + +```bash +git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): content-aware skeleton loading screens" +``` + +--- + +## Task 10: Session Duration Bar + Active Highlight + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (modify `renderSessionRow` and `refreshSessionsTable`) +- Modify: `cmd/web-ui/static/style.css` (~20 lines) + +Add a small visual duration bar in the sessions table showing relative session length, and highlight active sessions with a subtle accent left-border. + +**Step 1: Add session row enhancement CSS** + +```css +tr.clickable.active { + border-left: 2px solid var(--accent); + background: var(--accent-dim); +} + +.session-duration-bar { + display: inline-block; + height: 4px; + background: var(--accent); + border-radius: 2px; + min-width: 4px; + max-width: 80px; + opacity: 0.6; + margin-left: 0.5rem; + vertical-align: middle; +} +``` + +**Step 2: Compute max duration and add bar to each row** + +In `refreshSessionsTable`, compute the longest session duration, then add a proportional bar element to each row's time column. + +**Step 3: Commit** + +```bash +git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): session duration bars and active session highlighting" +``` + +--- + +## Task 11: Global Error-Aware WS Handler + +**Files:** +- Modify: `cmd/web-ui/static/app.js` (add global WS listener for error badge, ~20 lines) + +Wire a persistent global WS subscription that runs across all pages (not cleaned up on route change) solely for error counting to support the header badge from Task 3. + +**Step 1: Add global WS subscription in DOMContentLoaded** + +```javascript +// Global error tracking — persists across page navigations +subscribeWS(function globalErrorTracker(msg) { + if (msg.type !== 'message') return; + if (getEnvelopeType(msg.data) === 'error') { + incrementErrorBadge(); + } +}); +``` + +This goes in the `DOMContentLoaded` handler, after the search input setup. + +**Step 2: Commit** + +```bash +git add cmd/web-ui/static/app.js +git commit -m "feat(web-ui): global WebSocket error tracking for header badge" +``` + +--- + +## Task 12: Polish Pass — Header Kbd Hint, Focus Rings, Hover States + +**Files:** +- Modify: `cmd/web-ui/static/index.html` (add Cmd+K hint to header) +- Modify: `cmd/web-ui/static/style.css` (~40 lines of polish) + +Final polish: add a ⌘K hint button next to the search bar, improve focus ring visibility for keyboard navigation, and refine hover states on interactive elements. + +**Step 1: Add Cmd+K button to header** + +In `index.html`, add after the search `/`: + +```html + +``` + +**Step 2: Add polish CSS** + +```css +.cmd-k-hint { + display: flex; + align-items: center; + background: var(--surface-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 0.2rem 0.5rem; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.7rem; + cursor: pointer; + transition: all 150ms; +} + +.cmd-k-hint:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* Better focus rings for keyboard navigation */ +a:focus-visible, +button:focus-visible, +tr.clickable:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius); +} + +/* Smooth hover lift on cards */ +.vm-card:hover, +.service-card:hover, +.agent-lane:hover { + transform: translateY(-2px); + transition: transform 200ms ease, box-shadow 200ms ease; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); +} +``` + +**Step 3: Wire the Cmd+K button** + +```javascript +document.getElementById('cmd-k-hint')?.addEventListener('click', openCommandPalette); +``` + +**Step 4: Commit** + +```bash +git add cmd/web-ui/static/index.html cmd/web-ui/static/app.js cmd/web-ui/static/style.css +git commit -m "feat(web-ui): polish pass — Cmd+K hint, focus rings, hover states" +``` + +--- + +## Execution Order + +Tasks are designed to be independent and can be done in any order, but the recommended sequence groups related work: + +1. **Task 1** — Command Palette (foundation for keyboard navigation) +2. **Task 3 + 11** — Error badge + global WS handler (small, linked) +3. **Task 2** — Animated counters (dashboard polish) +4. **Task 5** — Dashboard sparklines (dashboard data richness) +5. **Task 4** — Session filter pills (sessions UX) +6. **Task 10** — Session duration bars (sessions UX) +7. **Task 6** — Agent lane sparklines (agents data richness) +8. **Task 8** — Infrastructure uptime + freshness (infra UX) +9. **Task 7** — Page transitions (global polish) +10. **Task 9** — Better skeletons (global polish) +11. **Task 12** — Final polish pass + +Total: ~12 tasks, ~500 lines of JS, ~300 lines of CSS. All pure frontend, no backend changes.
Host${escapeHTML(inst.host || '-')}
Domain${escapeHTML(inst.domain || '-')}