Files
agentmon/cmd/web-ui/static/app.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

148 lines
5.0 KiB
JavaScript

// ── app.js — entry point ──────────────────────────────────
// Thin bootstrap: imports modules, wires DOM events, fires route().
// Navigation and copy-to-clipboard are wired via delegated click listeners
// below — no inline onclick= attributes, no window.* globals.
import { navigate, route } from './modules/router.js';
import { connectWS, subscribeWS, updateWSIndicator } from './modules/ws.js';
import { applyTheme, getTheme, updateToggleBtn, cycleTheme } from './modules/theme.js';
import { copyToClipboard } from './modules/api.js';
import { getEnvelopeType } from './modules/utils.js';
import {
incrementErrorBadge,
openCommandPalette,
closeCommandPalette,
isCommandPaletteOpen,
handleGlobalSearch,
} from './modules/palette.js';
// ── Delegated click handlers ──────────────────────────────
// 1) Same-origin <a> clicks → SPA navigation (no full page reload).
// 2) .copy-btn[data-copy] → copyToClipboard.
function isInternalLinkClick(e, a) {
if (e.defaultPrevented) return false;
if (e.button !== 0) return false;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return false;
if (a.target && a.target !== '' && a.target !== '_self') return false;
if (a.hasAttribute('download')) return false;
const href = a.getAttribute('href');
if (!href) return false;
if (href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return false;
let url;
try {
url = new URL(href, window.location.href);
} catch {
return false;
}
if (url.origin !== window.location.origin) return false;
return url;
}
document.addEventListener('click', (e) => {
// Copy buttons first — stop the click from reaching a surrounding row link.
const copyBtn = e.target.closest('.copy-btn[data-copy]');
if (copyBtn) {
e.preventDefault();
e.stopPropagation();
copyToClipboard(copyBtn.getAttribute('data-copy'), copyBtn);
return;
}
const a = e.target.closest('a');
if (!a) return;
const url = isInternalLinkClick(e, a);
if (!url) return;
e.preventDefault();
navigate(url.pathname + url.search + url.hash);
});
// ── Bootstrap ─────────────────────────────────────────────
applyTheme(getTheme());
document.addEventListener('DOMContentLoaded', function() {
updateToggleBtn(getTheme());
const btn = document.getElementById('theme-toggle');
if (btn) btn.addEventListener('click', cycleTheme);
// Global Search
const searchInput = document.getElementById('global-search');
if (searchInput) {
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const val = searchInput.value.trim();
if (val) handleGlobalSearch(val);
}
});
}
// Cmd+K hint button — show platform-appropriate label
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgentData?.platform || '');
const cmdKBtn = document.getElementById('cmd-k-hint');
if (cmdKBtn) {
cmdKBtn.innerHTML = `<kbd>${isMac ? '⌘K' : 'Ctrl+K'}</kbd>`;
cmdKBtn.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) => {
// 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();
if (isCommandPaletteOpen()) closeCommandPalette();
else openCommandPalette();
return;
}
// '/' to focus search
if (e.key === '/' && !isCommandPaletteOpen()) {
e.preventDefault();
const si = document.getElementById('global-search');
if (si) si.focus();
return;
}
// Escape closes palette
if (e.key === 'Escape' && isCommandPaletteOpen()) {
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');
else if (e.key === 'p') navigate('/settings');
else if (e.key === 'u') navigate('/usage');
return;
}
});
connectWS();
updateWSIndicator();
route();
});