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>
This commit is contained in:
William Valentin
2026-04-23 15:36:12 -07:00
parent 41b7165800
commit 184aa5e6cb
20 changed files with 5129 additions and 4216 deletions
+107
View File
@@ -0,0 +1,107 @@
import { app, isRouteCurrent } from '../router.js';
import { api } from '../api.js';
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
export async function renderUsage(routeToken) {
app.innerHTML = `
<div class="page-header"><h2>Usage</h2></div>
<div id="usage-content"><div class="usage-loading">Loading…</div></div>
`;
const [summary, toolsData, modelsData, tsData] = await Promise.all([
api('/v1/stats/summary').catch(() => null),
api('/v1/stats/top-tools?limit=20').catch(() => ({ tools: [] })),
api('/v1/stats/top-models?limit=10').catch(() => ({ models: [] })),
api('/v1/stats/timeseries?window=7d').catch(() => ({ series: [] })),
]);
if (routeToken && !isRouteCurrent(routeToken)) return;
const tools = toolsData.tools || [];
const models = modelsData.models || [];
const series = tsData.series || [];
// Aggregate 7d totals from timeseries
const totals7d = series.reduce((acc, b) => {
acc.runs += b.runs || 0;
acc.tools += b.tools || 0;
acc.errors += b.errors || 0;
acc.tokens += b.tokens || 0;
acc.cost += b.cost || 0;
return acc;
}, { runs: 0, tools: 0, errors: 0, tokens: 0, cost: 0 });
const s = summary || {};
const content = document.getElementById('usage-content');
if (!content) return;
content.innerHTML = `
<div class="usage-summary-tiles">
<div class="meta-tile"><div class="meta-tile-label">Active Sessions</div><div class="meta-tile-value">${s.active_sessions || 0}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Runs Today</div><div class="meta-tile-value">${s.runs_today || 0}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Tool Calls Today</div><div class="meta-tile-value">${s.tool_calls_today || 0}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Errors Today</div><div class="meta-tile-value">${s.errors_today || 0}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Tokens Today</div><div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div></div>
<div class="meta-tile"><div class="meta-tile-label">Cost Today</div><div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div></div>
</div>
<div class="usage-section-row">
<div class="usage-panel">
<div class="section-title">7-Day Totals</div>
<div class="usage-7d-tiles">
<div class="usage-7d-tile"><span class="usage-7d-label">Runs</span><strong>${totals7d.runs}</strong></div>
<div class="usage-7d-tile"><span class="usage-7d-label">Tool Calls</span><strong>${totals7d.tools}</strong></div>
<div class="usage-7d-tile"><span class="usage-7d-label">Errors</span><strong>${totals7d.errors}</strong></div>
<div class="usage-7d-tile"><span class="usage-7d-label">Tokens</span><strong>${formatTokenCount(totals7d.tokens)}</strong></div>
<div class="usage-7d-tile"><span class="usage-7d-label">Est. Cost</span><strong>${formatCost(totals7d.cost)}</strong></div>
</div>
</div>
</div>
<div class="usage-section-row">
<div class="usage-panel">
<div class="section-title">Top Models <span class="count">${models.length}</span></div>
${models.length === 0 ? '<p class="empty-state">No model data yet</p>' : `
<ul class="stat-list" id="usage-models-list">
${(() => {
const max = models[0]?.count || 1;
return models.map(m => {
const pct = (m.count / max * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(m.name)}</span>
<span class="stat-list-count">${m.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
</div>
</li>`;
}).join('');
})()}
</ul>`}
</div>
<div class="usage-panel">
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
${tools.length === 0 ? '<p class="empty-state">No tool data yet</p>' : `
<ul class="stat-list" id="usage-tools-list">
${(() => {
const max = tools[0]?.count || 1;
return tools.map(t => {
const pct = (t.count / max * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(t.name)}</span>
<span class="stat-list-count">${t.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill tool" style="width:${pct}%"></div>
</div>
</li>`;
}).join('');
})()}
</ul>`}
</div>
</div>
`;
}