8753c0c9d5
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>
158 lines
5.5 KiB
JavaScript
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);
|