Files
William Valentin 8753c0c9d5 feat(web-ui): better stats and ergonomics
Usage page: add 7-day trend chart (activity/tokens/cost tabs),
framework breakdown panel with per-framework run/tool/error counts
and proportional bars, and 7d aggregate pills above the chart.

Dashboard: add avg cost/run metric pill to the metrics strip.

Run detail: extract and display prompt preview from the first agent
span's payload above the spans table.

Bug fixes: stat-list bars now render correctly (flex-direction:column),
right-panel-tab active background uses correct accent color, missing
framework colors added for hermes/codex/gemini/copilot. Dead code
renderSessionRow removed from sessions.js. Hardcoded font-family
replaced with CSS variable in metric-pill-value and token-stat-value.
Usage page cleanup() wired into router teardown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:49:05 -07:00

158 lines
5.5 KiB
JavaScript

// ── router.js — SPA routing, navigation, breadcrumbs ─────
// Circular imports with page modules are safe: all cross-module
// accesses happen inside function bodies, never at module init time.
import { escapeHTML } from './utils.js';
import { agentsState } from './state.js';
import { resetNavController } from './nav-signal.js';
import { renderDashboard, cleanup as cleanupDashboard } from './pages/dashboard.js';
import { renderSessions, cleanup as cleanupSessions } from './pages/sessions.js';
import { renderSession, cleanup as cleanupSessionDetail } from './pages/session-detail.js';
import { renderRun, cleanup as cleanupRunDetail } from './pages/run-detail.js';
import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js';
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
import { renderSettings } from './pages/settings.js';
import { renderUsage, cleanup as cleanupUsage } from './pages/usage.js';
// Exported so all page modules can write into it without querying the DOM each time
export const app = document.getElementById('app');
let currentRouteToken = 0;
export function isRouteCurrent(token) {
return token === currentRouteToken;
}
export function cleanupLiveViews() {
cleanupInfra();
cleanupAgents();
cleanupSessions();
cleanupSessionDetail();
cleanupRunDetail();
cleanupDashboard();
cleanupUsage();
}
export function route() {
const routeToken = ++currentRouteToken;
resetNavController();
cleanupLiveViews();
renderBreadcrumbs();
app.classList.add('transitioning');
const path = window.location.pathname;
const safeRender = (fn) => {
if (!isRouteCurrent(routeToken)) return;
Promise.resolve().then(() => {
if (!isRouteCurrent(routeToken)) return null;
return fn(routeToken);
}).catch(err => {
if (!isRouteCurrent(routeToken)) return;
if (err && err.name === 'AbortError') return; // route changed mid-fetch
console.error('Render error:', err);
app.innerHTML = `
<div class="error-boundary">
<h2>Something went wrong</h2>
<p>An error occurred while rendering this page.</p>
<pre class="error-boundary-detail">${escapeHTML(err.message)}</pre>
<a href="/" class="back-link">Back to Dashboard</a>
</div>
`;
});
};
if (path === '/') {
safeRender((token) => renderDashboard(token));
} else if (path === '/sessions') {
safeRender((token) => renderSessions(token));
} else if (path.startsWith('/agents/')) {
const agentKey = decodeURIComponent(path.split('/agents/')[1]);
safeRender((token) => renderAgents(agentKey, token));
} else if (path.startsWith('/agents')) {
safeRender((token) => renderAgents(undefined, token));
} else if (path.startsWith('/infrastructure')) {
safeRender((token) => renderInfrastructure(token));
} else if (path.startsWith('/sessions/')) {
safeRender((token) => renderSession(path.split('/sessions/')[1], token));
} else if (path.startsWith('/runs/')) {
safeRender((token) => renderRun(path.split('/runs/')[1], token));
} else if (path === '/settings') {
safeRender((token) => renderSettings(token));
} else if (path === '/usage') {
safeRender((token) => renderUsage(token));
} 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();
requestAnimationFrame(() => {
if (isRouteCurrent(routeToken)) app.classList.remove('transitioning');
});
}
export function renderBreadcrumbs() {
const el = document.getElementById('breadcrumbs');
if (!el) return;
const path = window.location.pathname;
const parts = path.split('/').filter(Boolean);
if (parts.length === 0) {
el.innerHTML = '';
return;
}
const items = [{ label: 'Dashboard', path: '/' }];
let currentPath = '';
parts.forEach((part, i) => {
currentPath += '/' + part;
let label = part.charAt(0).toUpperCase() + part.slice(1);
// Special labels for IDs
if (part.length > 20 || /^[a-f0-9-]{32,}$/.test(part)) {
label = part.substring(0, 8) + '…';
}
// For /agents/:key, show the human-readable agent name
if (parts[0] === 'agents' && i === 1) {
const agentKey = decodeURIComponent(part);
const agent = agentsState && agentsState.agents && agentsState.agents[agentKey];
label = (agent && agent.name) ? agent.name : agentKey;
}
if (i === parts.length - 1) {
items.push({ label, current: true });
} else {
items.push({ label, path: currentPath });
}
});
// No inline onclick — the delegated <a> click handler in app.js picks these up.
el.innerHTML = items.map(item => {
if (item.current) {
return `<span class="current">${escapeHTML(item.label)}</span>`;
}
return `<a href="${escapeHTML(item.path)}">${escapeHTML(item.label)}</a><span class="sep">/</span>`;
}).join('');
}
export function navigate(path) {
history.pushState(null, '', path);
route();
}
export function updateActiveNav() {
const path = window.location.pathname;
document.querySelectorAll('header nav a').forEach(a => {
const href = a.getAttribute('href');
const isActive = href === '/'
? path === '/'
: path.startsWith(href);
a.classList.toggle('active', isActive);
});
}
window.addEventListener('popstate', route);