feat(web-ui): improve navigation and session UX

This commit is contained in:
William Valentin
2026-04-21 13:07:05 -07:00
parent 8f766b4019
commit 43113f6241
6 changed files with 2056 additions and 46 deletions
+9
View File
@@ -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)
+484 -45
View File
@@ -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 @@
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="9" height="9" rx="1.5"/><path d="M10.5 1.5H2.5A1.5 1.5 0 0 0 1 3v8"/></svg>
</button>`;
}
// ── 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 = `
<div class="cmd-palette">
<div class="cmd-palette-input-wrap">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="5"/><line x1="11" y1="11" x2="14" y2="14"/></svg>
<input class="cmd-palette-input" id="cmd-palette-input" type="text" placeholder="Type a command or search..." autofocus spellcheck="false" autocomplete="off">
</div>
<div class="cmd-palette-results" id="cmd-palette-results"></div>
<div class="cmd-palette-footer">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd>↵</kbd> select</span>
<span><kbd>esc</kbd> close</span>
</div>
</div>
`;
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) => `
<div class="cmd-palette-item${i === paletteSelectedIndex ? ' selected' : ''}" data-index="${i}">
<div class="cmd-palette-icon">${item.icon}</div>
<span class="cmd-palette-label"><strong>${escapeHTML(item.label)}</strong></span>
${item.shortcut ? `<span class="cmd-palette-kbd">${item.shortcut}</span>` : ''}
</div>
`).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 = '<div class="not-found"><h2>Page not found</h2><p>The page you\'re looking for doesn\'t exist.</p><a href="/" class="back-link">Go to Dashboard</a></div>';
}
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 = '<div class="not-found"><h2>Page not found</h2><p>The page you\'re looking for doesn\'t exist.</p><a href="/" class="back-link">Go to Dashboard</a></div>';
}
updateActiveNav();
// Fade back in
requestAnimationFrame(() => {
app.classList.remove('transitioning');
});
}, 80);
});
}
function renderBreadcrumbs() {
@@ -365,6 +597,58 @@
.replace(/'/g, '&#39;');
}
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 `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" class="summary-card-sparkline">
<path d="${areaPath}" fill="${color}" opacity="0.3"/>
<polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="1.5"/>
</svg>`;
}
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 `
<div class="skeleton-summary-row">${Array(4).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:40%"></div><div class="skeleton-line" style="width:20%;height:2rem"></div></div>').join('')}</div>
<div class="skeleton-line" style="width:100%;height:200px;border-radius:var(--radius-lg)"></div>
`;
}
function sessionsSkeleton() {
// Returns <tr> rows suitable for insertion into a <tbody>
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 `<tr>
<td><div style="display:flex;align-items:center;gap:0.6rem"><div class="skeleton-circle"></div><div style="flex:1"><div class="skeleton-line" style="width:${w1};margin-bottom:0.3rem"></div><div class="skeleton-line" style="width:${w2}"></div></div></div></td>
<td><div class="skeleton-line" style="width:60%"></div></td>
<td><div class="skeleton-line" style="width:55%"></div></td>
<td><div class="skeleton-line" style="width:30%"></div></td>
<td><div class="skeleton-line" style="width:45%"></div></td>
</tr>`;
}).join('');
}
function agentsSkeleton() {
return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem">
${Array(4).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:50%"></div><div class="skeleton-line" style="width:70%"></div><div class="skeleton-line" style="width:30%"></div></div>').join('')}
</div>`;
}
function infrastructureSkeleton() {
return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem">
${Array(6).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:40%"></div><div class="skeleton-line" style="width:60%"></div><div class="skeleton-line" style="width:25%"></div></div>').join('')}
</div>`;
}
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 = '<tr><td colspan="5" class="empty-state">No sessions found</td></tr>';
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 = `<span class="session-duration-bar" style="width:${barWidth.toFixed(0)}px"></span>`;
return `
<tr class="clickable ${active ? 'active' : ''}" data-session="${escapeHTML(s.session_id)}">
<tr class="${rowClass}" data-session="${escapeHTML(s.session_id)}">
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}${renderCopyButton(s.session_id)}</td>
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
<td>${escapeHTML(s.host || '-')}</td>
<td>${s.run_count}</td>
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}${durationBar}</td>
</tr>`;
}).join('');
return `<tr class="session-date-group"><td colspan="5">${escapeHTML(group.label)}</td></tr>${rows}`;
@@ -567,6 +932,9 @@
}
async function renderSessions() {
// Reset filter mode on each page visit
sessionFilterMode = 'all';
app.innerHTML = `
<div class="page-header">
<h2>Sessions</h2>
@@ -581,6 +949,12 @@
</label>
<label>Host <input type="text" id="filter-host" placeholder="hostname"></label>
</div>
<div class="filter-pills" id="session-pills">
<button class="filter-pill active" data-filter="all">All</button>
<button class="filter-pill" data-filter="active">Active</button>
<button class="filter-pill" data-filter="ended">Ended</button>
<button class="filter-pill" data-filter="errored">With Errors</button>
</div>
<div class="table-container">
<table>
<thead>
@@ -592,12 +966,22 @@
<th>Time</th>
</tr>
</thead>
<tbody id="sessions-body">${skeletonRows(8, 5)}</tbody>
<tbody id="sessions-body">${sessionsSkeleton()}</tbody>
</table>
</div>
<button id="load-more" class="load-more" style="display:none">Load more</button>
`;
// 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 = '<div class="page-header"><h2>Infrastructure</h2></div><p class="empty-state">Loading...</p>';
app.innerHTML = `<div class="page-header"><h2>Infrastructure</h2></div><div style="margin-top:1rem">${infrastructureSkeleton()}</div>`;
infraUnsubscribe = subscribeWS(handleInfraWS);
@@ -1364,6 +1760,14 @@
}
</div>
`;
// 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'}
</div>
</div>
<div class="vm-updated">Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}</div>
<div class="vm-updated"><span class="freshness-timer" data-ts="${escapeHTML(getEnvelopeTS(evt))}">Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}</span></div>
<table class="vm-stats">
<tr><td>Host</td><td>${escapeHTML(inst.host || '-')}</td></tr>
<tr><td>Domain</td><td>${escapeHTML(inst.domain || '-')}</td></tr>
@@ -1434,10 +1838,11 @@
}
function serviceCardHeader(svc) {
const uptimeBadge = getUptimeBadge(svc.uptime_sec);
return `
<div class="service-card-header">
<div>
<div class="service-card-name">${escapeHTML(svc.name)}</div>
<div class="service-card-name">${escapeHTML(svc.name)}${uptimeBadge ? ' ' + uptimeBadge : ''}</div>
<div class="service-role-tag">${escapeHTML(svc.role || '')}</div>
</div>
<span class="service-badge ${escapeHTML(svc.status || 'down')}">${escapeHTML(svc.status || 'down')}</span>
@@ -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 `<span class="uptime-badge ${cls}">${pct.toFixed(0)}% / 24h</span>`;
}
function formatUptime(sec) {
if (!sec) return '-';
if (sec < 60) return sec + 's';
@@ -1950,7 +2364,7 @@
</div>
</div>
<div class="agents-summary-row" id="agents-summary"></div>
<div id="agents-content"><p class="empty-state">Loading...</p></div>
<div id="agents-content">${agentsSkeleton()}</div>
`;
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 `<div class="agent-lane-sparkline">${buckets.map(b => {
const pct = (b / max * 100).toFixed(0);
return `<div class="agent-lane-sparkline-bar" style="height:${Math.max(pct, 3)}%"></div>`;
}).join('')}</div>`;
}
function renderAgentLanes() {
const contentEl = document.getElementById('agents-content');
if (!contentEl) return;
@@ -2108,6 +2547,7 @@
${escapeHTML(agent.name || key)}
</div>
<div class="agent-lane-meta">${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}</div>
${buildAgentActivityBars(agent)}
</div>
<span class="agent-lane-status${statusClass}">${statusText}</span>
</div>
@@ -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() {
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0d1117"/>
<stop offset="1" stop-color="#111820"/>
</linearGradient>
<linearGradient id="accent" x1="20" y1="18" x2="49" y2="47" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#67e8f9"/>
<stop offset="1" stop-color="#22d3ee"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="52" height="52" rx="14" fill="url(#bg)"/>
<path d="M22 44V24.5L32 20l10 4.5V44" fill="none" stroke="url(#accent)" stroke-linecap="round" stroke-linejoin="round" stroke-width="5"/>
<circle cx="46" cy="46" r="4" fill="#22d3ee"/>
</svg>

After

Width:  |  Height:  |  Size: 759 B

+5 -1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>agentmon</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
@@ -16,11 +17,14 @@
<div class="header-logo">
<h1><a href="/">agentmon<span class="logo-dot"></span></a></h1>
</div>
<nav><a href="/">Dashboard</a><a href="/sessions">Sessions</a><a href="/agents">Agents</a><a href="/infrastructure">Infra</a></nav>
<nav><a href="/">Dashboard<span class="nav-badge" id="nav-error-badge"></span></a><a href="/sessions">Sessions</a><a href="/agents">Agents</a><a href="/infrastructure">Infra</a></nav>
<div class="header-right">
<div class="header-search">
<input type="text" id="global-search" placeholder="Search ID..." spellcheck="false" autocomplete="off">
<kbd>/</kbd>
<button class="cmd-k-hint" id="cmd-k-hint" title="Command palette" type="button">
<kbd>⌘K</kbd>
</button>
</div>
<span class="ws-dot" id="ws-dot" title="Disconnected"></span>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
+375
View File
@@ -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);
}