fester/ui/app.js

315 lines
12 KiB
JavaScript

/* ============================================================
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 `<span class="badge ${cls}">${state || 'pending'}</span>`;
};
/* ---------- 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 =>
`<a href="${p.href}" class="${p.id === active ? 'active' : ''}">${p.label}</a>`
).join('');
const shell = document.createElement('div');
shell.className = 'topbar';
shell.innerHTML = `
<div class="brand">
<span class="mark"></span>
<span class="label">FESTER</span>
</div>
<nav>${links}</nav>
<div class="conn connecting" data-conn title="WebSocket status">
<span class="dot"></span>
<span data-conn-text>CONNECTING</span>
</div>
<div class="health" data-health title="Backend health" style="display:flex;align-items:center;gap:4px;font-size:10px;font-family:var(--font-mono);color:var(--fg-3);padding:4px 8px;border-radius:var(--radius);background:var(--bg-3);border:1px solid var(--line-2);">
<span>—</span>
</div>
`;
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 = `
<span style="color:var(--ok);">●</span>
<span>v${h.version}</span>
<span class="dim">·</span>
<span>${ws}ws</span>
<span class="dim">·</span>
<span>${builds}b</span>
<span class="dim">·</span>
<span>${events}e</span>
`;
el.style.color = 'var(--fg-1)';
}
} catch (e) {
const el = document.querySelector('[data-health]');
if (el) {
el.innerHTML = `<span style="color:var(--err);">●</span><span>unreachable</span>`;
}
}
};
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;
})();