Files
agentmon/cmd/web-ui/static/modules/router.js
T
William Valentin 184aa5e6cb fix(web-ui): security hardening, SPA nav, and modularization
Ship the in-progress ES-module refactor of the web-ui (new static/modules/
layout, Usage/Settings pages, uplot-based dashboard) alongside a round of
security and UX fixes:

- main.go: add CSP + X-Frame-Options: DENY + X-Content-Type-Options:
  nosniff + Referrer-Policy middleware on every response; WS CheckOrigin
  now requires Origin host to match Host (blocks cross-site WebSocket
  hijacking); upgrade client before dialing upstream so origin check
  runs first; fatal on unparseable AGENTMON_QUERY_BASE.
- app.js: delegated click handler intercepts same-origin <a> clicks for
  SPA navigation (prev. every nav link caused a full page reload,
  dropping WS + in-memory state); delegated .copy-btn[data-copy]
  handler replaces inline onclick=; removed window.navigate /
  window.copyToClipboard globals and the duplicated handleGlobalSearch.
- modules/nav-signal.js: per-route AbortController so in-flight fetches
  are cancelled when the user navigates away, preventing stale toasts
  and wasted renders.
- modules/api.js: honours the nav signal by default; AbortError is
  silent.
- modules/router.js: resets the nav controller on every route; dropped
  the fixed 80ms transition delay; breadcrumbs no longer emit inline
  onclick= (delegated handler picks them up).
- modules/utils.js: renderCopyButton emits data-copy=\"...\" instead of
  nesting a JS string inside an HTML attribute — fixes an XSS where
  values containing ' broke out via &#39; decoding.

Verified: go build clean; `node --check` clean on all modified modules;
manual curl probes confirm security headers present on every response
and WS upgrade returns 403 for cross-origin/missing Origin while 101
for same-origin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 15:36:12 -07:00

157 lines
5.4 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 } 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();
}
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);