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>
213 lines
7.2 KiB
JavaScript
213 lines
7.2 KiB
JavaScript
// ── palette.js — command palette, error badge, global search
|
|
|
|
import { escapeHTML } from './utils.js';
|
|
import { api, showToast } from './api.js';
|
|
import { cycleTheme } from './theme.js';
|
|
import { agentsState, isAgentOnline } from './state.js';
|
|
import { navigate } from './router.js';
|
|
import { selectAgent } from './pages/agents.js';
|
|
|
|
// ── Error badge ──────────────────────────────────────────
|
|
|
|
let _unseenErrors = 0;
|
|
|
|
export function incrementErrorBadge() {
|
|
if (window.location.pathname === '/') return; // On dashboard, don't badge
|
|
_unseenErrors++;
|
|
const badge = document.getElementById('nav-error-badge');
|
|
if (badge) {
|
|
badge.textContent = _unseenErrors > 99 ? '99+' : String(_unseenErrors);
|
|
badge.classList.add('visible');
|
|
}
|
|
}
|
|
|
|
export function clearErrorBadge() {
|
|
_unseenErrors = 0;
|
|
const badge = document.getElementById('nav-error-badge');
|
|
if (badge) {
|
|
badge.classList.remove('visible');
|
|
badge.textContent = '';
|
|
}
|
|
}
|
|
|
|
// ── Command palette ──────────────────────────────────────
|
|
|
|
let paletteOpen = false;
|
|
let paletteSelectedIndex = 0;
|
|
|
|
export function isCommandPaletteOpen() { return paletteOpen; }
|
|
|
|
export function getCommandPaletteItems(query) {
|
|
const items = [
|
|
{ label: 'Dashboard', path: '/', icon: '◉', shortcut: 'g d' },
|
|
{ label: 'Sessions', path: '/sessions', icon: '▶', shortcut: 'g s' },
|
|
{ label: 'Agents', path: '/agents', icon: '◎', shortcut: 'g a' },
|
|
{ label: 'Infrastructure', path: '/infrastructure', icon: '⚡', shortcut: 'g i' },
|
|
{ label: 'Settings', path: '/settings', icon: '⚙', shortcut: 'g p' },
|
|
{ label: 'Usage', path: '/usage', icon: '◈', shortcut: 'g u' },
|
|
{ label: 'Toggle Theme', action: 'theme', icon: '◐' },
|
|
];
|
|
|
|
// Add agent items dynamically
|
|
if (agentsState && agentsState.agents) {
|
|
for (const [key, agent] of Object.entries(agentsState.agents)) {
|
|
items.push({
|
|
label: 'Agent: ' + (agent.name || key),
|
|
path: '/agents',
|
|
action: 'select-agent',
|
|
agentKey: key,
|
|
icon: isAgentOnline(agent) ? '●' : '○',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!query) return items;
|
|
const q = query.toLowerCase();
|
|
return items.filter(item => item.label.toLowerCase().includes(q));
|
|
}
|
|
|
|
export function openCommandPalette() {
|
|
if (paletteOpen) return;
|
|
paletteOpen = true;
|
|
paletteSelectedIndex = 0;
|
|
|
|
const backdrop = document.createElement('div');
|
|
backdrop.className = 'cmd-palette-backdrop';
|
|
backdrop.id = 'cmd-palette-backdrop';
|
|
backdrop.innerHTML = `
|
|
<div class="cmd-palette">
|
|
<div class="cmd-palette-input-wrap">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="5"/><line x1="11" y1="11" x2="14" y2="14"/></svg>
|
|
<input class="cmd-palette-input" id="cmd-palette-input" type="text" placeholder="Type a command or search..." autofocus spellcheck="false" autocomplete="off">
|
|
</div>
|
|
<div class="cmd-palette-results" id="cmd-palette-results"></div>
|
|
<div class="cmd-palette-footer">
|
|
<span><kbd>↑↓</kbd> navigate</span>
|
|
<span><kbd>↵</kbd> select</span>
|
|
<span><kbd>esc</kbd> close</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(backdrop);
|
|
const input = document.getElementById('cmd-palette-input');
|
|
input.focus();
|
|
renderPaletteItems('');
|
|
|
|
input.addEventListener('input', () => {
|
|
paletteSelectedIndex = 0;
|
|
renderPaletteItems(input.value);
|
|
});
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
const items = document.querySelectorAll('.cmd-palette-item');
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
paletteSelectedIndex = Math.min(paletteSelectedIndex + 1, items.length - 1);
|
|
updatePaletteSelection();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
paletteSelectedIndex = Math.max(paletteSelectedIndex - 1, 0);
|
|
updatePaletteSelection();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const selected = items[paletteSelectedIndex];
|
|
if (selected) selected.click();
|
|
} else if (e.key === 'Escape') {
|
|
closeCommandPalette();
|
|
}
|
|
});
|
|
|
|
backdrop.addEventListener('click', (e) => {
|
|
if (e.target === backdrop) closeCommandPalette();
|
|
});
|
|
}
|
|
|
|
export function closeCommandPalette() {
|
|
paletteOpen = false;
|
|
const backdrop = document.getElementById('cmd-palette-backdrop');
|
|
if (backdrop) backdrop.remove();
|
|
}
|
|
|
|
function renderPaletteItems(query) {
|
|
const container = document.getElementById('cmd-palette-results');
|
|
if (!container) return;
|
|
const items = getCommandPaletteItems(query);
|
|
|
|
// If query looks like an ID (4+ hex chars), add a search option
|
|
if (query.length >= 4) {
|
|
items.unshift({ label: 'Search: ' + query, action: 'search', query, icon: '🔍' });
|
|
}
|
|
|
|
container.innerHTML = items.map((item, i) => `
|
|
<div class="cmd-palette-item${i === paletteSelectedIndex ? ' selected' : ''}" data-index="${i}">
|
|
<div class="cmd-palette-icon">${item.icon}</div>
|
|
<span class="cmd-palette-label"><strong>${escapeHTML(item.label)}</strong></span>
|
|
${item.shortcut ? `<span class="cmd-palette-kbd">${item.shortcut}</span>` : ''}
|
|
</div>
|
|
`).join('');
|
|
|
|
container.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
|
|
el.addEventListener('click', () => executePaletteItem(items[i]));
|
|
el.addEventListener('mouseenter', () => {
|
|
paletteSelectedIndex = i;
|
|
updatePaletteSelection();
|
|
});
|
|
});
|
|
}
|
|
|
|
function updatePaletteSelection() {
|
|
document.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
|
|
el.classList.toggle('selected', i === paletteSelectedIndex);
|
|
if (i === paletteSelectedIndex) el.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
function executePaletteItem(item) {
|
|
closeCommandPalette();
|
|
if (item.action === 'theme') {
|
|
cycleTheme();
|
|
} else if (item.action === 'search') {
|
|
handleGlobalSearch(item.query);
|
|
} else if (item.action === 'select-agent') {
|
|
navigate('/agents');
|
|
setTimeout(() => selectAgent(item.agentKey, 'live'), 100);
|
|
} else if (item.path) {
|
|
navigate(item.path);
|
|
}
|
|
}
|
|
|
|
// ── Global search ────────────────────────────────────────
|
|
|
|
export async function handleGlobalSearch(query) {
|
|
query = query.trim();
|
|
if (query.length < 4) {
|
|
showToast('Enter at least 4 characters', 'info');
|
|
return;
|
|
}
|
|
|
|
// Hex ID pattern — try as session or run
|
|
if (/^[a-f0-9-]{4,}$/i.test(query)) {
|
|
try {
|
|
const sessionData = await api('/v1/sessions/' + query).catch(() => null);
|
|
if (sessionData && sessionData.session) {
|
|
navigate('/sessions/' + query);
|
|
return;
|
|
}
|
|
|
|
const runData = await api('/v1/runs/' + query).catch(() => null);
|
|
if (runData && runData.run) {
|
|
navigate('/runs/' + query);
|
|
return;
|
|
}
|
|
|
|
showToast('ID not found', 'error');
|
|
} catch (e) {
|
|
showToast('Search failed: ' + e.message, 'error');
|
|
}
|
|
} else {
|
|
// Non-hex: treat as framework/host search
|
|
navigate('/sessions?framework=' + encodeURIComponent(query));
|
|
}
|
|
}
|