/* ============================================================ Fester UI — Shared application shell & utilities Used by: index.html, ui/live_dag.html, ui/replay.html Exposes a single global: window.Fester ============================================================ */ (function () { 'use strict'; const Fester = { ws: null, wsUrl: (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws', wsDebuggerUrl: (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws-debugger', wsTargetsUrl: (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws-targets', wsState: 'connecting', _subs: new Set(), _reconnectTimer: null, _reconnectDelay: 800, }; /* ---------- Event subscription ---------- callback(event) — event is the parsed JSON object from /ws returns an unsubscribe function */ Fester.subscribe = function (cb) { Fester._subs.add(cb); return () => Fester._subs.delete(cb); }; function dispatch(event) { Fester._subs.forEach(cb => { try { cb(event); } catch (e) { console.error('subscriber error', e); } }); } /* ---------- WebSocket with auto-reconnect ---------- */ function setConnState(state) { Fester.wsState = state; document.querySelectorAll('[data-conn]').forEach(el => { el.classList.remove('online', 'offline', 'connecting'); el.classList.add(state); const txt = el.querySelector('[data-conn-text]'); if (txt) txt.textContent = state.toUpperCase(); }); } function connect() { setConnState('connecting'); try { const ws = new WebSocket(Fester.wsUrl); Fester.ws = ws; ws.onopen = () => { setConnState('online'); Fester._reconnectDelay = 800; Fester.toast('Connected to Fester event stream', 'ok'); }; ws.onmessage = (msg) => { try { const event = JSON.parse(msg.data); dispatch(event); } catch (e) { console.error('bad ws payload', e, msg.data); } }; ws.onerror = (e) => { console.warn('ws error', e); }; ws.onclose = () => { setConnState('offline'); if (!Fester._reconnectTimer) { Fester._reconnectTimer = setTimeout(() => { Fester._reconnectTimer = null; connect(); }, Fester._reconnectDelay); Fester._reconnectDelay = Math.min(Fester._reconnectDelay * 1.6, 8000); } }; } catch (e) { setConnState('offline'); console.error('ws connect failed', e); } } Fester.connect = connect; Fester.send = function (obj) { if (Fester.ws && Fester.ws.readyState === WebSocket.OPEN) { Fester.ws.send(JSON.stringify(obj)); return true; } return false; }; /* ---------- REST helpers ---------- */ Fester.api = async function (path, opts = {}) { const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts, }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); const ct = res.headers.get('content-type') || ''; return ct.includes('application/json') ? res.json() : res.text(); }; /* ---------- State → color helpers (mirrors backend fester.js logic) ---------- */ Fester.heatColor = function (heat) { if (heat == null) return 'var(--fg-3)'; if (heat < 30) return 'var(--heat-0)'; if (heat < 60) return 'var(--heat-1)'; if (heat < 80) return 'var(--heat-2)'; return 'var(--heat-3)'; }; Fester.stateColor = function (state) { switch ((state || '').toLowerCase()) { case 'done': case 'ok': case 'success': return 'var(--ok)'; case 'running': case 'active': return 'var(--warn)'; case 'failed': case 'error': return 'var(--err)'; case 'cache': case 'hit': return 'var(--info)'; default: return 'var(--fg-3)'; } }; Fester.stateBadge = function (state) { const s = (state || 'pending').toLowerCase(); const map = { done: 'ok', ok: 'ok', success: 'ok', running: 'warn', active: 'warn', failed: 'err', error: 'err', cache: 'info', hit: 'info', pending: '', queued: '', }; const cls = map[s] || ''; return `${state || 'pending'}`; }; /* ---------- Toasts ---------- */ Fester.toast = function (msg, kind = 'info', ms = 3200) { let host = document.getElementById('toasts'); if (!host) { host = document.createElement('div'); host.id = 'toasts'; document.body.appendChild(host); } const el = document.createElement('div'); el.className = `toast ${kind}`; el.textContent = msg; host.appendChild(el); setTimeout(() => { el.style.transition = 'opacity 0.3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, ms); }; /* ---------- Time formatting ---------- */ Fester.fmtTime = function (ts) { if (!ts) return '—'; const d = new Date(ts * 1000 || ts); const pad = n => String(n).padStart(2, '0'); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }; Fester.fmtRel = function (ts) { if (!ts) return '—'; const diff = Math.max(0, (Date.now() - (ts * 1000 || ts)) / 1000); if (diff < 60) return Math.floor(diff) + 's ago'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; return Math.floor(diff / 3600) + 'h ago'; }; /* ---------- Topbar shell ---------- Injects shared topbar so every page has consistent nav + WS status. Call Fester.mountShell('dashboard') etc. */ Fester.mountShell = function (active) { const pages = [ { id: 'dashboard', href: '/index.html', label: 'Dashboard' }, { id: 'live', href: '/ui/live_dag.html', label: 'Live DAG' }, { id: 'replay', href: '/ui/replay.html', label: 'Replay' }, { id: 'sessions', href: '/ui/sessions.html', label: 'Sessions' }, { id: 'metrics', href: '/ui/metrics.html', label: 'Metrics' }, { id: 'cause', href: '/ui/cause.html', label: 'Cause Graph' }, { id: 'timeline', href: '/ui/timeline.html', label: 'Timeline' }, { id: 'debugger', href: '/ui/debugger.html', label: 'Debugger' }, ]; const links = pages.map(p => `${p.label}` ).join(''); const shell = document.createElement('div'); shell.className = 'topbar'; shell.innerHTML = `
FESTER
CONNECTING
`; document.body.prepend(shell); // Start health polling Fester._startHealthPolling(); }; /* ---------- Health polling ---------- */ Fester._healthTimer = null; Fester._startHealthPolling = function () { if (Fester._healthTimer) return; const tick = async () => { try { const h = await Fester.api('/api/health'); const el = document.querySelector('[data-health]'); if (el) { const subs = h.bus_subscribers || 0; const ws = h.ws_clients || 0; const builds = h.builds_known || 0; const events = h.timeline_events || 0; el.innerHTML = ` v${h.version} · ${ws}ws · ${builds}b · ${events}e `; el.style.color = 'var(--fg-1)'; } } catch (e) { const el = document.querySelector('[data-health]'); if (el) { el.innerHTML = `unreachable`; } } }; tick(); Fester._healthTimer = setInterval(tick, 5000); }; /* ---------- FesterTargets global ---------- The missing API referenced by live_dag.html. Maintains an in-memory snapshot of nodes + edges, notifies subscribers on update. */ const snapshot = { nodes: [], edges: [], byId: {} }; const targetSubs = new Set(); Fester.targets = { get() { return snapshot; }, onUpdate(cb) { targetSubs.add(cb); // immediately emit current snapshot try { cb(snapshot); } catch (e) { console.error(e); } return () => targetSubs.delete(cb); }, update(patch) { if (patch.nodes) { patch.nodes.forEach(n => { const existing = snapshot.byId[n.id]; snapshot.byId[n.id] = existing ? { ...existing, ...n } : n; }); snapshot.nodes = Object.values(snapshot.byId); } if (patch.edges) { // merge by from+to const seen = new Set(snapshot.edges.map(e => e.from + '→' + e.to)); patch.edges.forEach(e => { const k = e.from + '→' + e.to; if (!seen.has(k)) { snapshot.edges.push(e); seen.add(k); } }); } targetSubs.forEach(cb => { try { cb(snapshot); } catch (e) { console.error(e); } }); }, reset() { snapshot.nodes = []; snapshot.edges = []; snapshot.byId = {}; targetSubs.forEach(cb => cb(snapshot)); }, }; // legacy alias used by live_dag.html window.FesterTargets = Fester.targets; /* ---------- Auto-init on DOMContentLoaded ---------- */ document.addEventListener('DOMContentLoaded', () => { // mount shell if a marker exists const marker = document.querySelector('[data-fester-shell]'); if (marker) { Fester.mountShell(marker.getAttribute('data-fester-shell')); } // auto-connect WS unless page opts out if (!document.body.hasAttribute('data-no-ws')) { connect(); } }); window.Fester = Fester; })();