feat(web-ui): improve navigation and session UX
This commit is contained in:
@@ -99,6 +99,15 @@ func main() {
|
|||||||
staticFS, _ := fs.Sub(staticFiles, "static")
|
staticFS, _ := fs.Sub(staticFiles, "static")
|
||||||
fileServer := http.FileServer(http.FS(staticFS))
|
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) {
|
mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/static")
|
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/static")
|
||||||
fileServer.ServeHTTP(w, r)
|
fileServer.ServeHTTP(w, r)
|
||||||
|
|||||||
+467
-28
@@ -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
|
// Keyboard Shortcuts
|
||||||
|
let _pendingGoto = false;
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
// '/' to focus search
|
// Ignore when typing in inputs
|
||||||
if (e.key === '/' && document.activeElement !== searchInput && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
|
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();
|
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>
|
<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>`;
|
</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');
|
const app = document.getElementById('app');
|
||||||
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
@@ -116,9 +331,12 @@
|
|||||||
|
|
||||||
let sessionsState = { sessions: [], cursor: null, activeSessionByBackend: {} };
|
let sessionsState = { sessions: [], cursor: null, activeSessionByBackend: {} };
|
||||||
let sessionsUnsubscribe = null;
|
let sessionsUnsubscribe = null;
|
||||||
|
// ── Session Filter Pills ────────────────────────────────
|
||||||
|
let sessionFilterMode = 'all';
|
||||||
let openclawState = { instances: {} };
|
let openclawState = { instances: {} };
|
||||||
let openclawUnsubscribe = null;
|
let openclawUnsubscribe = null;
|
||||||
let infraUnsubscribe = null;
|
let infraUnsubscribe = null;
|
||||||
|
let _infraTimerInterval = null;
|
||||||
let swarmState = { services: {} }; // keyed by service name
|
let swarmState = { services: {} }; // keyed by service name
|
||||||
let agentsState = createAgentsState();
|
let agentsState = createAgentsState();
|
||||||
let agentsUnsubscribe = null;
|
let agentsUnsubscribe = null;
|
||||||
@@ -212,6 +430,10 @@
|
|||||||
infraUnsubscribe();
|
infraUnsubscribe();
|
||||||
infraUnsubscribe = null;
|
infraUnsubscribe = null;
|
||||||
}
|
}
|
||||||
|
if (_infraTimerInterval) {
|
||||||
|
clearInterval(_infraTimerInterval);
|
||||||
|
_infraTimerInterval = null;
|
||||||
|
}
|
||||||
if (agentsUnsubscribe) {
|
if (agentsUnsubscribe) {
|
||||||
agentsUnsubscribe();
|
agentsUnsubscribe();
|
||||||
agentsUnsubscribe = null;
|
agentsUnsubscribe = null;
|
||||||
@@ -254,6 +476,9 @@
|
|||||||
cleanupLiveViews();
|
cleanupLiveViews();
|
||||||
renderBreadcrumbs();
|
renderBreadcrumbs();
|
||||||
|
|
||||||
|
app.classList.add('transitioning');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(() => {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
@@ -271,6 +496,13 @@
|
|||||||
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>';
|
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();
|
updateActiveNav();
|
||||||
|
|
||||||
|
// Fade back in
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
app.classList.remove('transitioning');
|
||||||
|
});
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBreadcrumbs() {
|
function renderBreadcrumbs() {
|
||||||
@@ -365,6 +597,58 @@
|
|||||||
.replace(/'/g, ''');
|
.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) {
|
function relativeTime(ts) {
|
||||||
if (!ts) {
|
if (!ts) {
|
||||||
return '-';
|
return '-';
|
||||||
@@ -411,6 +695,41 @@
|
|||||||
).join('');
|
).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) {
|
function extractEnvelope(record) {
|
||||||
if (record && typeof record === 'object' && record.payload && record.payload.event && record.payload.schema) {
|
if (record && typeof record === 'object' && record.payload && record.payload.event && record.payload.schema) {
|
||||||
return record.payload;
|
return record.payload;
|
||||||
@@ -536,11 +855,51 @@
|
|||||||
function refreshSessionsTable() {
|
function refreshSessionsTable() {
|
||||||
const tbody = document.getElementById('sessions-body');
|
const tbody = document.getElementById('sessions-body');
|
||||||
if (!tbody) return;
|
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) {
|
if (groups.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No sessions found</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No sessions found</td></tr>';
|
||||||
return;
|
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 => {
|
tbody.innerHTML = groups.map(group => {
|
||||||
const rows = group.items.map(s => {
|
const rows = group.items.map(s => {
|
||||||
const fw = s.framework || 'unknown';
|
const fw = s.framework || 'unknown';
|
||||||
@@ -550,13 +909,19 @@
|
|||||||
const dotTitle = dotState === 'active'
|
const dotTitle = dotState === 'active'
|
||||||
? 'Currently active session'
|
? 'Currently active session'
|
||||||
: (active ? 'Open session' : 'Session ended');
|
: (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 `
|
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 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><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
||||||
<td>${escapeHTML(s.host || '-')}</td>
|
<td>${escapeHTML(s.host || '-')}</td>
|
||||||
<td>${s.run_count}</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>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
return `<tr class="session-date-group"><td colspan="5">${escapeHTML(group.label)}</td></tr>${rows}`;
|
return `<tr class="session-date-group"><td colspan="5">${escapeHTML(group.label)}</td></tr>${rows}`;
|
||||||
@@ -567,6 +932,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderSessions() {
|
async function renderSessions() {
|
||||||
|
// Reset filter mode on each page visit
|
||||||
|
sessionFilterMode = 'all';
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Sessions</h2>
|
<h2>Sessions</h2>
|
||||||
@@ -581,6 +949,12 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>Host <input type="text" id="filter-host" placeholder="hostname"></label>
|
<label>Host <input type="text" id="filter-host" placeholder="hostname"></label>
|
||||||
</div>
|
</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">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -592,12 +966,22 @@
|
|||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="sessions-body">${skeletonRows(8, 5)}</tbody>
|
<tbody id="sessions-body">${sessionsSkeleton()}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<button id="load-more" class="load-more" style="display:none">Load more</button>
|
<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 => {
|
api('/v1/stats/summary').then(data => {
|
||||||
const sel = document.getElementById('filter-framework');
|
const sel = document.getElementById('filter-framework');
|
||||||
if (!sel || !data.by_framework) return;
|
if (!sel || !data.by_framework) return;
|
||||||
@@ -668,7 +1052,12 @@
|
|||||||
const row = tbody.querySelector(`[data-session="${s.session_id}"]`);
|
const row = tbody.querySelector(`[data-session="${s.session_id}"]`);
|
||||||
if (row) {
|
if (row) {
|
||||||
const td = row.cells[4];
|
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();
|
refreshSessionsTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,7 +1232,7 @@
|
|||||||
const correlation = getEnvelopeCorrelation(msg.data);
|
const correlation = getEnvelopeCorrelation(msg.data);
|
||||||
if (correlation?.session_id !== sessionID) return;
|
if (correlation?.session_id !== sessionID) return;
|
||||||
const eventType = getEnvelopeType(msg.data);
|
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);
|
clearTimeout(_sessionReloadTimer);
|
||||||
_sessionReloadTimer = setTimeout(() => loadSessionData(sessionID), 300);
|
_sessionReloadTimer = setTimeout(() => loadSessionData(sessionID), 300);
|
||||||
}
|
}
|
||||||
@@ -1241,7 +1637,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderInfrastructure() {
|
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);
|
infraUnsubscribe = subscribeWS(handleInfraWS);
|
||||||
|
|
||||||
@@ -1364,6 +1760,14 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</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) {
|
function renderVMCard(name) {
|
||||||
@@ -1382,7 +1786,7 @@
|
|||||||
${host.state === 'running' ? 'Running' : 'Stopped'}
|
${host.state === 'running' ? 'Running' : 'Stopped'}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<table class="vm-stats">
|
||||||
<tr><td>Host</td><td>${escapeHTML(inst.host || '-')}</td></tr>
|
<tr><td>Host</td><td>${escapeHTML(inst.host || '-')}</td></tr>
|
||||||
<tr><td>Domain</td><td>${escapeHTML(inst.domain || '-')}</td></tr>
|
<tr><td>Domain</td><td>${escapeHTML(inst.domain || '-')}</td></tr>
|
||||||
@@ -1434,10 +1838,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serviceCardHeader(svc) {
|
function serviceCardHeader(svc) {
|
||||||
|
const uptimeBadge = getUptimeBadge(svc.uptime_sec);
|
||||||
return `
|
return `
|
||||||
<div class="service-card-header">
|
<div class="service-card-header">
|
||||||
<div>
|
<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 class="service-role-tag">${escapeHTML(svc.role || '')}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="service-badge ${escapeHTML(svc.status || 'down')}">${escapeHTML(svc.status || 'down')}</span>
|
<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) {
|
function formatUptime(sec) {
|
||||||
if (!sec) return '-';
|
if (!sec) return '-';
|
||||||
if (sec < 60) return sec + 's';
|
if (sec < 60) return sec + 's';
|
||||||
@@ -1950,7 +2364,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="agents-summary-row" id="agents-summary"></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();
|
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() {
|
function renderAgentLanes() {
|
||||||
const contentEl = document.getElementById('agents-content');
|
const contentEl = document.getElementById('agents-content');
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
@@ -2108,6 +2547,7 @@
|
|||||||
${escapeHTML(agent.name || key)}
|
${escapeHTML(agent.name || key)}
|
||||||
</div>
|
</div>
|
||||||
<div class="agent-lane-meta">${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}</div>
|
<div class="agent-lane-meta">${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}</div>
|
||||||
|
${buildAgentActivityBars(agent)}
|
||||||
</div>
|
</div>
|
||||||
<span class="agent-lane-status${statusClass}">${statusText}</span>
|
<span class="agent-lane-status${statusClass}">${statusText}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2790,6 +3230,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderDashboard() {
|
async function renderDashboard() {
|
||||||
|
clearErrorBadge();
|
||||||
dashboardState = {
|
dashboardState = {
|
||||||
summary: null,
|
summary: null,
|
||||||
timeseries: null,
|
timeseries: null,
|
||||||
@@ -2970,7 +3411,7 @@
|
|||||||
const cachedSummary = tryParseJSON(localStorage.getItem('agentmon:dash:summary'));
|
const cachedSummary = tryParseJSON(localStorage.getItem('agentmon:dash:summary'));
|
||||||
const cachedTS = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window));
|
const cachedTS = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window));
|
||||||
if (cachedSummary) { dashboardState.summary = cachedSummary; renderSummaryCards(); }
|
if (cachedSummary) { dashboardState.summary = cachedSummary; renderSummaryCards(); }
|
||||||
if (cachedTS) { dashboardState.timeseries = cachedTS; renderTimeseriesChart(); renderRightPanel(); }
|
if (cachedTS) { dashboardState.timeseries = cachedTS; renderTimeseriesChart(); renderDashSparklines(); renderRightPanel(); }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [summaryData, tsData, recentData, snapshots, swarmSnaps, topToolsData, topModelsData] = await Promise.all([
|
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));
|
localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(tsData));
|
||||||
renderSummaryCards();
|
renderSummaryCards();
|
||||||
renderTimeseriesChart();
|
renderTimeseriesChart();
|
||||||
|
renderDashSparklines();
|
||||||
renderRightPanel();
|
renderRightPanel();
|
||||||
|
|
||||||
// Seed tool counts from the dedicated top-tools endpoint
|
// Seed tool counts from the dedicated top-tools endpoint
|
||||||
@@ -3149,15 +3591,10 @@
|
|||||||
const s = dashboardState.summary;
|
const s = dashboardState.summary;
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
|
|
||||||
const el = (id, val) => {
|
animateCounter('dash-active', s.active_sessions);
|
||||||
const e = document.getElementById(id);
|
animateCounter('dash-runs', s.runs_today);
|
||||||
if (e) e.textContent = String(val);
|
animateCounter('dash-tools', s.tool_calls_today);
|
||||||
};
|
animateCounter('dash-errors', s.errors_today);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Sub-line: framework breakdown for active sessions
|
// Sub-line: framework breakdown for active sessions
|
||||||
const fws = Object.keys(s.by_framework || {});
|
const fws = Object.keys(s.by_framework || {});
|
||||||
@@ -3172,15 +3609,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Metrics strip
|
// Metrics strip
|
||||||
el('dash-tokens-today', formatTokenCount(s.tokens_today || 0));
|
animateCounter('dash-tokens-today', formatTokenCount(s.tokens_today || 0));
|
||||||
el('dash-cost-today', s.cost_today ? formatCost(s.cost_today) : '$0.0000');
|
animateCounter('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-avg-duration', s.avg_duration_ms ? formatDuration(s.avg_duration_ms) : '-');
|
||||||
|
|
||||||
const errorRateEl = document.getElementById('dash-error-rate');
|
const errorRateEl = document.getElementById('dash-error-rate');
|
||||||
if (errorRateEl) {
|
if (errorRateEl) {
|
||||||
const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0);
|
const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0);
|
||||||
const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 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);
|
errorRateEl.classList.toggle('alert', rate > 5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3194,12 +3631,13 @@
|
|||||||
}
|
}
|
||||||
dashboardState.chartCursorIndex = null;
|
dashboardState.chartCursorIndex = null;
|
||||||
const cachedWin = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window));
|
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);
|
const data = await api('/v1/stats/timeseries?window=' + dashboardState.window);
|
||||||
if (!isCurrentPath('/')) return;
|
if (!isCurrentPath('/')) return;
|
||||||
dashboardState.timeseries = data;
|
dashboardState.timeseries = data;
|
||||||
localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(data));
|
localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(data));
|
||||||
renderTimeseriesChart();
|
renderTimeseriesChart();
|
||||||
|
renderDashSparklines();
|
||||||
renderRightPanel();
|
renderRightPanel();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load timeseries:', e);
|
console.error('Failed to load timeseries:', e);
|
||||||
@@ -3500,6 +3938,7 @@
|
|||||||
|
|
||||||
dashboardState.chartCursorIndex = ts.series.length - 1;
|
dashboardState.chartCursorIndex = ts.series.length - 1;
|
||||||
renderTimeseriesChart();
|
renderTimeseriesChart();
|
||||||
|
renderDashSparklines();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRightPanel() {
|
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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>agentmon</title>
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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">
|
<div class="header-logo">
|
||||||
<h1><a href="/">agentmon<span class="logo-dot"></span></a></h1>
|
<h1><a href="/">agentmon<span class="logo-dot"></span></a></h1>
|
||||||
</div>
|
</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-right">
|
||||||
<div class="header-search">
|
<div class="header-search">
|
||||||
<input type="text" id="global-search" placeholder="Search ID..." spellcheck="false" autocomplete="off">
|
<input type="text" id="global-search" placeholder="Search ID..." spellcheck="false" autocomplete="off">
|
||||||
<kbd>/</kbd>
|
<kbd>/</kbd>
|
||||||
|
<button class="cmd-k-hint" id="cmd-k-hint" title="Command palette" type="button">
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="ws-dot" id="ws-dot" title="Disconnected"></span>
|
<span class="ws-dot" id="ws-dot" title="Disconnected"></span>
|
||||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
|
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
|
||||||
|
|||||||
@@ -150,6 +150,35 @@ header nav a.active::after {
|
|||||||
border-radius: 1px;
|
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 ────────────────────────────────────────── */
|
||||||
.header-search {
|
.header-search {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -458,6 +487,43 @@ tr.tr-error .id-cell {
|
|||||||
100% { transform: translateX(100%); }
|
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 badges ─────────────────────────────────────────── */
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -570,6 +636,141 @@ tr:hover .copy-btn,
|
|||||||
.toast-error { border-left: 3px solid var(--error); }
|
.toast-error { border-left: 3px solid var(--error); }
|
||||||
.toast-info { border-left: 3px solid var(--accent); }
|
.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 ─────────────────────────────────────────────── */
|
||||||
.load-more {
|
.load-more {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -752,6 +953,8 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
.vm-card:hover {
|
.vm-card:hover {
|
||||||
border-color: rgba(34, 211, 238, 0.18);
|
border-color: rgba(34, 211, 238, 0.18);
|
||||||
transform: translateY(-2px);
|
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; }
|
.vm-card:hover::before { opacity: 1; }
|
||||||
@@ -1299,6 +1502,8 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.125rem 1.25rem;
|
padding: 1.125rem 1.25rem;
|
||||||
transition: border-color 0.2s, border-left-color 0.2s;
|
transition: border-color 0.2s, border-left-color 0.2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card:hover {
|
.summary-card:hover {
|
||||||
@@ -1336,6 +1541,21 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
color: var(--error);
|
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 {
|
.summary-card-sub {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -1343,6 +1563,17 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
font-family: var(--font-mono);
|
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 {
|
.charts-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 320px;
|
grid-template-columns: 1fr 320px;
|
||||||
@@ -1998,6 +2229,12 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
cursor: pointer;
|
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 {
|
.agent-lane-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2025,6 +2262,23 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
color: var(--text-dim);
|
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 {
|
.agent-lane-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
@@ -2491,6 +2745,9 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
|
|
||||||
.service-card:hover {
|
.service-card:hover {
|
||||||
border-color: rgba(34, 211, 238, 0.15);
|
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 {
|
.service-card-header {
|
||||||
@@ -2650,6 +2907,65 @@ tr.session-date-group:first-child > td {
|
|||||||
padding-top: 0.5rem;
|
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 ──────────────────────────────────────── */
|
||||||
.span-kind-badge {
|
.span-kind-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -3315,6 +3631,26 @@ tr.session-date-group:first-child > td {
|
|||||||
margin-bottom: 1.5rem;
|
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 loading ─────────────────────────────────── */
|
||||||
.skeleton-line {
|
.skeleton-line {
|
||||||
height: 0.85rem;
|
height: 0.85rem;
|
||||||
@@ -3350,3 +3686,42 @@ tr.run-span-row[tabindex="0"]:focus-visible {
|
|||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: -2px;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user