feat(web-ui): improve navigation and session UX
This commit is contained in:
@@ -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
@@ -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, ''');
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user