315 lines
12 KiB
JavaScript
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;
|
|
})();
|