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 = 'No sessions found'; 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 = ``; const errorCell = (s._errorCount || 0) > 0 ? `${s._errorCount}` : ''; return ` ${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)} ${escapeHTML(fw)} ${escapeHTML(s.host || '-')} ${s.run_count} ${escapeHTML(relativeTime(s.started_at))}${durationBar} ${errorCell} `; }).join(''); return `${escapeHTML(group.label)}${rows}`; }).join(''); tbody.querySelectorAll('tr.clickable').forEach(row => { row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session)); }); updatePaginationInfo(); } 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 = `
${sessionsSkeleton()}
Session Framework Host Runs Time Errors
`; // 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); }