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>
481 lines
18 KiB
JavaScript
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);
|
|
}
|