184aa5e6cb
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 ' 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>
148 lines
5.0 KiB
JavaScript
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();
|
|
});
|