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

481 lines
18 KiB
JavaScript

import { app, navigate, isRouteCurrent } from '../router.js';
import { api } from '../api.js';
import {
escapeHTML, relativeTime,
getEnvelopeType, getEnvelopeCorrelation, getEnvelopeSource, getEnvelopeTS,
isCurrentPath, renderCopyButton, sessionsSkeleton,
} from '../utils.js';
import { subscribeWS } from '../ws.js';
let sessionsState = { sessions: [], cursor: null, total: 0, activeSessionByBackend: {} };
let sessionsPageUnsubscribe = null;
let sessionFilterMode = 'all';
let sessionSortKey = 'started_at';
let sessionSortDir = 'desc';
export function cleanup() {
if (sessionsPageUnsubscribe) { sessionsPageUnsubscribe(); sessionsPageUnsubscribe = null; }
if (sessionsState.timerInterval) { clearInterval(sessionsState.timerInterval); }
sessionsState = { sessions: [], cursor: null, total: 0, activeSessionByBackend: {} };
}
function isSessionActive(s) { return !s.ended_at; }
function sortSessions(sessions) {
return [...sessions].sort((a, b) => {
let av = a[sessionSortKey], bv = b[sessionSortKey];
if (sessionSortKey === 'started_at') {
av = new Date(av).getTime();
bv = new Date(bv).getTime();
} else if (typeof av === 'string') {
av = av.toLowerCase();
bv = (bv || '').toLowerCase();
}
if (av == null) av = 0;
if (bv == null) bv = 0;
if (av < bv) return sessionSortDir === 'asc' ? -1 : 1;
if (av > bv) return sessionSortDir === 'asc' ? 1 : -1;
return 0;
});
}
function groupSessionsByDate(sessions) {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart);
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
const weekStart = new Date(todayStart);
weekStart.setDate(weekStart.getDate() - 6);
const groups = [
{ label: 'Today', items: [] },
{ label: 'Yesterday', items: [] },
{ label: 'This Week', items: [] },
{ label: 'Older', items: [] },
];
for (const s of sessions) {
const d = new Date(s.started_at);
if (d >= todayStart) groups[0].items.push(s);
else if (d >= yesterdayStart) groups[1].items.push(s);
else if (d >= weekStart) groups[2].items.push(s);
else groups[3].items.push(s);
}
return groups.filter(g => g.items.length > 0);
}
function getSessionBackendKey(s) {
const framework = s.framework || 'unknown';
const backendID = s.client_id || s.host || 'unknown';
return `${framework}|${backendID}`;
}
function sessionActivityTS(s) {
const raw = s._lastActivityTS || Date.parse(s.started_at);
return Number.isFinite(raw) ? raw : 0;
}
function recomputeActiveSessionByBackend() {
const next = {};
const bestTS = {};
sessionsState.sessions.forEach(s => {
if (!isSessionActive(s)) return;
const key = getSessionBackendKey(s);
const ts = sessionActivityTS(s);
if (!next[key] || ts > bestTS[key]) {
next[key] = s.session_id;
bestTS[key] = ts;
}
});
sessionsState.activeSessionByBackend = next;
}
function sessionDotState(s) {
if (!isSessionActive(s)) return 'ended';
const key = getSessionBackendKey(s);
const activeSessionID = sessionsState.activeSessionByBackend[key];
return activeSessionID === s.session_id ? 'active' : 'idle';
}
function touchSessionActivity(sessionID, ts, source) {
const session = sessionsState.sessions.find(s => s.session_id === sessionID);
if (!session) return null;
const parsedTS = Date.parse(ts || '');
const activityTS = Number.isFinite(parsedTS) ? parsedTS : Date.now();
session._lastActivityTS = Math.max(session._lastActivityTS || 0, activityTS);
if (source && typeof source === 'object') {
if (source.framework) session.framework = source.framework;
if (source.host) session.host = source.host;
if (source.client_id) session.client_id = source.client_id;
}
const key = getSessionBackendKey(session);
sessionsState.activeSessionByBackend[key] = session.session_id;
return session;
}
function updatePaginationInfo() {
const el = document.getElementById('pagination-info');
if (!el) return;
const loaded = sessionsState.sessions.length;
const total = sessionsState.total || loaded;
let filtered = loaded;
if (sessionFilterMode === 'active') {
filtered = sessionsState.sessions.filter(s => isSessionActive(s)).length;
} else if (sessionFilterMode === 'ended') {
filtered = sessionsState.sessions.filter(s => !isSessionActive(s)).length;
} else if (sessionFilterMode === 'errored') {
filtered = sessionsState.sessions.filter(s => (s._errorCount || 0) > 0).length;
}
if (filtered < loaded) {
el.textContent = `Showing ${filtered} of ${loaded} loaded (${total} total)`;
} else {
el.textContent = `Showing ${loaded} of ${total}`;
}
}
function refreshSessionsTable() {
const tbody = document.getElementById('sessions-body');
if (!tbody) return;
// Update pill counts based on full unfiltered sessions list
const all = sessionsState.sessions;
const activeCount = all.filter(s => isSessionActive(s)).length;
const endedCount = all.filter(s => !isSessionActive(s)).length;
const erroredCount = all.filter(s => (s._errorCount || 0) > 0).length;
const pillDefs = [
{ filter: 'all', count: all.length },
{ filter: 'active', count: activeCount },
{ filter: 'ended', count: endedCount },
{ filter: 'errored', count: erroredCount },
];
pillDefs.forEach(({ filter, count }) => {
const btn = document.querySelector(`#session-pills [data-filter="${filter}"]`);
if (!btn) return;
let countEl = btn.querySelector('.pill-count');
if (!countEl) {
countEl = document.createElement('span');
countEl.className = 'pill-count';
btn.appendChild(countEl);
}
countEl.textContent = count;
});
// Apply filter
let filtered = sessionsState.sessions;
if (sessionFilterMode === 'active') {
filtered = filtered.filter(s => isSessionActive(s));
} else if (sessionFilterMode === 'ended') {
filtered = filtered.filter(s => !isSessionActive(s));
} else if (sessionFilterMode === 'errored') {
filtered = filtered.filter(s => (s._errorCount || 0) > 0);
}
const groups = groupSessionsByDate(sortSessions(filtered));
if (groups.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">No sessions found</td></tr>';
return;
}
// Update sort indicator classes on headers
document.querySelectorAll('th.sortable').forEach(th => {
th.classList.remove('sort-asc', 'sort-desc');
if (th.dataset.sort === sessionSortKey) {
th.classList.add(sessionSortDir === 'asc' ? 'sort-asc' : 'sort-desc');
}
});
const allFiltered = groups.flatMap(g => g.items);
const maxDuration = Math.max(...allFiltered.map(s => {
const start = new Date(s.started_at).getTime();
const end = s.ended_at ? new Date(s.ended_at).getTime() : Date.now();
return end - start;
}), 1);
tbody.innerHTML = groups.map(group => {
const rows = group.items.map(s => {
const fw = s.framework || 'unknown';
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
const active = isSessionActive(s);
const dotState = sessionDotState(s);
const dotTitle = dotState === 'active'
? 'Currently active session'
: (active ? 'Open session' : 'Session ended');
const rowClass = active ? 'clickable active-session' : 'clickable';
const start = new Date(s.started_at).getTime();
const end = s.ended_at ? new Date(s.ended_at).getTime() : Date.now();
const duration = end - start;
const barWidth = Math.max(4, (duration / maxDuration) * 80);
const durationBar = `<span class="session-duration-bar" style="width:${barWidth.toFixed(0)}px"></span>`;
const errorCell = (s._errorCount || 0) > 0
? `<span class="error-count-badge">${s._errorCount}</span>`
: '<span style="color:var(--text-dim)">—</span>';
return `
<tr class="${rowClass}" data-session="${escapeHTML(s.session_id)}">
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}${renderCopyButton(s.session_id)}</td>
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
<td>${escapeHTML(s.host || '-')}</td>
<td>${s.run_count}</td>
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}${durationBar}</td>
<td>${errorCell}</td>
</tr>`;
}).join('');
return `<tr class="session-date-group"><td colspan="6">${escapeHTML(group.label)}</td></tr>${rows}`;
}).join('');
tbody.querySelectorAll('tr.clickable').forEach(row => {
row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session));
});
updatePaginationInfo();
}
// Dead code: renderSessionRow is never called but preserved for fidelity
function renderSessionRow(s) { // eslint-disable-line no-unused-vars
const fw = s.framework || 'unknown';
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
const active = isSessionActive(s);
const dotState = sessionDotState(s);
const dotTitle = dotState === 'active'
? 'Currently active session'
: (active ? 'Open session' : 'Session ended');
const errorCell = (s._errorCount || 0) > 0
? `<span class="error-count-badge">${s._errorCount}</span>`
: '<span style="color:var(--text-dim)">—</span>';
return `
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}${renderCopyButton(s.session_id)}</td>
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
<td>${escapeHTML(s.host || '-')}</td>
<td>${s.run_count}</td>
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
<td>${errorCell}</td>
`;
}
function updateSessionTimers() {
const tbody = document.getElementById('sessions-body');
if (!tbody) return;
sessionsState.sessions.forEach(s => {
const row = tbody.querySelector(`[data-session="${s.session_id}"]`);
if (row) {
const td = row.cells[4];
if (td) {
// Update only the text node, preserving the duration bar span
const bar = td.querySelector('.session-duration-bar');
td.textContent = relativeTime(s.started_at);
if (bar) td.appendChild(bar);
}
}
});
}
function handleSessionsWS(msg) {
if (msg.type !== 'message') return;
const eventType = getEnvelopeType(msg.data);
const correlation = getEnvelopeCorrelation(msg.data);
const source = getEnvelopeSource(msg.data);
const ts = getEnvelopeTS(msg.data);
const sessionId = correlation?.session_id || msg.data.event?.id;
if (eventType === 'session.start') {
const newSession = {
session_id: sessionId,
started_at: ts || new Date().toISOString(),
framework: source.framework || 'unknown',
client_id: source.client_id || '',
host: source.host || '-',
run_count: 1,
_lastActivityTS: Date.parse(ts || '') || Date.now(),
};
sessionsState.sessions.unshift(newSession);
const backendKey = getSessionBackendKey(newSession);
sessionsState.activeSessionByBackend[backendKey] = newSession.session_id;
refreshSessionsTable();
return;
}
const tbody = document.getElementById('sessions-body');
if (!tbody) return;
if (sessionId) {
touchSessionActivity(sessionId, ts, source);
}
if (eventType === 'run.start' && sessionId) {
const session = sessionsState.sessions.find(s => s.session_id === sessionId);
if (session) {
session.run_count = (session.run_count || 0) + 1;
const row = tbody.querySelector(`[data-session="${sessionId}"]`);
if (row && row.cells[3]) row.cells[3].textContent = session.run_count;
}
}
if (eventType === 'session.end' && sessionId) {
const session = sessionsState.sessions.find(s => s.session_id === sessionId);
if (session) {
session.ended_at = new Date().toISOString();
recomputeActiveSessionByBackend();
}
}
if (eventType === 'error' && sessionId) {
const session = sessionsState.sessions.find(s => s.session_id === sessionId);
if (session) {
session._errorCount = (session._errorCount || 0) + 1;
}
}
refreshSessionsTable();
}
async function loadSessions(routeToken) {
const params = new URLSearchParams();
const from = document.getElementById('filter-from').value;
const to = document.getElementById('filter-to').value;
const framework = document.getElementById('filter-framework').value;
const host = document.getElementById('filter-host').value;
// Sync filters to URL
const filterParams = new URLSearchParams();
if (from) filterParams.set('from', from);
if (to) filterParams.set('to', to);
if (framework) filterParams.set('framework', framework);
if (host) filterParams.set('host', host);
const filterQS = filterParams.toString();
const newURL = '/sessions' + (filterQS ? '?' + filterQS : '');
if (window.location.pathname + window.location.search !== newURL) {
history.replaceState(null, '', newURL);
}
if (from) params.set('from', from);
if (to) params.set('to', to);
if (framework) params.set('framework', framework);
if (host) params.set('host', host);
if (sessionsState.cursor) params.set('cursor', sessionsState.cursor);
const data = await api('/v1/sessions?' + params.toString());
if (routeToken && !isRouteCurrent(routeToken)) return;
const incoming = (data.sessions || []).map(s => ({
...s,
_lastActivityTS: Date.parse(s.started_at || '') || Date.now(),
}));
sessionsState.sessions = sessionsState.sessions.concat(incoming);
sessionsState.cursor = data.next_cursor;
// total is only returned on the first page (no cursor)
if (data.total !== undefined) sessionsState.total = data.total;
recomputeActiveSessionByBackend();
refreshSessionsTable();
document.getElementById('load-more').style.display = sessionsState.cursor ? 'block' : 'none';
}
export async function renderSessions(routeToken) {
// Reset filter mode on each page visit
sessionFilterMode = 'all';
app.innerHTML = `
<div class="page-header">
<h2>Sessions</h2>
</div>
<div class="filters">
<label>From <input type="date" id="filter-from"></label>
<label>To <input type="date" id="filter-to"></label>
<label>Framework
<select id="filter-framework">
<option value="">All</option>
</select>
</label>
<label>Host <input type="text" id="filter-host" placeholder="hostname"></label>
</div>
<div class="filter-pills" id="session-pills">
<button class="filter-pill active" data-filter="all">All</button>
<button class="filter-pill" data-filter="active">Active</button>
<button class="filter-pill" data-filter="ended">Ended</button>
<button class="filter-pill" data-filter="errored">With Errors</button>
</div>
<div class="pagination-info" id="pagination-info"></div>
<div class="table-container">
<table>
<thead>
<tr>
<th class="sortable" data-sort="session_id">Session <span class="sort-icon"></span></th>
<th class="sortable" data-sort="framework">Framework <span class="sort-icon"></span></th>
<th class="sortable" data-sort="host">Host <span class="sort-icon"></span></th>
<th class="sortable" data-sort="run_count">Runs <span class="sort-icon"></span></th>
<th class="sortable" data-sort="started_at">Time <span class="sort-icon"></span></th>
<th>Errors</th>
</tr>
</thead>
<tbody id="sessions-body">${sessionsSkeleton()}</tbody>
</table>
</div>
<button id="load-more" class="load-more" style="display:none">Load more</button>
`;
// Wire up filter pill click handlers
document.querySelectorAll('#session-pills .filter-pill').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#session-pills .filter-pill').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
sessionFilterMode = btn.dataset.filter;
refreshSessionsTable();
});
});
// Wire up sortable column headers
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (sessionSortKey === key) {
sessionSortDir = sessionSortDir === 'asc' ? 'desc' : 'asc';
} else {
sessionSortKey = key;
sessionSortDir = 'desc';
}
refreshSessionsTable();
});
});
api('/v1/stats/summary').then(data => {
if (routeToken && !isRouteCurrent(routeToken)) return;
const sel = document.getElementById('filter-framework');
if (!sel || !data.by_framework) return;
for (const fw of Object.keys(data.by_framework).sort()) {
const opt = document.createElement('option');
opt.value = fw;
opt.textContent = fw;
sel.appendChild(opt);
}
}).catch(() => {});
// Restore filters from URL
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('from')) document.getElementById('filter-from').value = urlParams.get('from');
if (urlParams.get('to')) document.getElementById('filter-to').value = urlParams.get('to');
if (urlParams.get('framework')) document.getElementById('filter-framework').value = urlParams.get('framework');
if (urlParams.get('host')) document.getElementById('filter-host').value = urlParams.get('host');
['from', 'to', 'framework'].forEach(f => {
document.getElementById('filter-' + f).addEventListener('change', () => {
sessionsState.sessions = [];
sessionsState.cursor = null;
loadSessions(routeToken);
});
});
let _hostDebounce = null;
document.getElementById('filter-host').addEventListener('input', () => {
clearTimeout(_hostDebounce);
_hostDebounce = setTimeout(() => {
sessionsState.sessions = [];
sessionsState.cursor = null;
loadSessions(routeToken);
}, 400);
});
document.getElementById('load-more').addEventListener('click', () => loadSessions(routeToken));
sessionsState = { sessions: [], cursor: null, total: 0, timerInterval: null, activeSessionByBackend: {} };
await loadSessions(routeToken);
if (routeToken && !isRouteCurrent(routeToken)) return;
sessionsState.timerInterval = setInterval(updateSessionTimers, 30000);
sessionsPageUnsubscribe = subscribeWS(handleSessionsWS);
}