// ── Oumta Dashboard plugin ── // // Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`). // The browser talks to /api/dashboard/* same-origin; the hyprcrd PHP // backend forwards to the real upstream (see OUMTA_DASHBOARD_URL in // config.php). Keeps CORS out of the browser and lets the dashboard // live on a private network reachable only by the hyprcrd container. const h = window.hyprcrd; const $ = id => document.getElementById(id); const CONFIG = { proxyPrefix: '/api/dashboard', pollIntervalSec: 15, }; const state = { namespaces: [], // [{ id, label, namespace }] activeNamespace: null, activeView: 'overview', liveMode: 'polling', // 'live' | 'polling' | 'error' | 'off' pollHandle: null, sseHandle: null, data: { overview: null, workloads: [], timeline: [], scopeWorkload: null }, timelineFilter: new Set(), // empty = all events; else any-of workloadIds (multi-select pills) }; // ── Central API surface ───────────────────────────────────────── // // Every backend call routes through `api()` against the same-origin // proxy prefix; the active namespace (if any) is attached as a // `namespace` query parameter. Auth / bearer tokens live server-side // in config.php, so nothing sensitive ships to the browser. Response // shapes are defined by the Go backend in // `internal/httpapi/contracts.go`. async function api(path, opts = {}) { const url = new URL(CONFIG.proxyPrefix + path, window.location.origin); if (!url.searchParams.has('namespace') && state.activeNamespace) { url.searchParams.set('namespace', state.activeNamespace.namespace); } const res = await fetch(url, opts); if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); return res.json(); } // ── Fullscreen overlay show/hide ───────────────────────────────── const overlay = $('oumta-overlay'); function isOpen() { return overlay?.classList.contains('open'); } function show() { if (!overlay) return; overlay.classList.add('open'); // Always kick off polling — refresh() self-heals: it loads namespaces // if they aren't there yet, and the interval keeps retrying even if // the initial poll fails. Previously an initial bootstrap failure // stopped the loop entirely and required a page reload to recover. refresh(); startLive(); } function hide() { if (!overlay) return; overlay.classList.remove('open'); stopLive(); } function toggle() { isOpen() ? hide() : show(); } window._oumtaToggle = toggle; function renderNamespaceTabs() { const host = $('oumta-ns-tabs'); if (!host) return; host.innerHTML = state.namespaces.map(n => ` `).join(''); host.querySelectorAll('.oumta-ns-tab').forEach(btn => { btn.addEventListener('click', () => { const ns = state.namespaces.find(x => x.id === btn.dataset.ns); if (ns && ns.id !== state.activeNamespace?.id) { state.activeNamespace = ns; renderNamespaceTabs(); refresh(); } }); }); } // ── View switching ──────────────────────────────────────────── document.addEventListener('click', e => { const tab = e.target.closest('.oumta-view-tab'); if (!tab || !overlay.contains(tab)) return; switchView(tab.dataset.view); }); function switchView(view) { state.activeView = view; document.querySelectorAll('.oumta-view-tab').forEach(b => b.classList.toggle('active', b.dataset.view === view)); document.querySelectorAll('.oumta-view').forEach(s => s.classList.toggle('hidden', s.dataset.view !== view)); // Reset workload-scoped timeline when leaving timeline view if (view !== 'timeline') state.data.scopeWorkload = null; renderTimeline(); } // ── Refresh all data for the active namespace ────────────────── // // Self-healing: if namespaces aren't loaded yet (e.g. initial poll // failed), fetch them first; if upstream is down, leave state as // 'error' and let the next interval tick try again. On any successful // poll we flip back to 'polling' so the indicator recovers without a // page reload. async function refresh() { try { if (!state.activeNamespace) { const res = await api('/api/namespaces'); state.namespaces = res.items || []; state.activeNamespace = state.namespaces[0] || null; renderNamespaceTabs(); } if (!state.activeNamespace) { // Namespaces endpoint returned empty — nothing to poll yet. // Stay in 'polling' so we keep retrying on the interval. setLive('polling'); return; } const [ov, wl, tl] = await Promise.all([ api('/api/cluster/overview'), api('/api/workloads'), api('/api/timeline'), ]); state.data.overview = ov; state.data.workloads = wl.items || []; state.data.timeline = tl.items || []; renderAll(); setLive('polling'); } catch (e) { setLive('error', e.message); } } // ── Renderers ───────────────────────────────────────────────── function renderAll() { renderOverview(); renderWorkloads(); renderTimeline(); const genAt = state.data.overview?.updatedAtUtc; const el = $('oumta-generated-at'); if (el && genAt) el.textContent = 'last import: ' + formatTime(genAt); } function renderOverview() { const o = state.data.overview; const wl = state.data.workloads || []; if (!o) return; // Values that come directly from the Go overview endpoint [REAL]. const clusterCard = $('oumta-cluster-card'); const clusterState = o.clusterState || 'unknown'; clusterCard.dataset.state = clusterState; clusterCard.classList.toggle('oumta-fail', clusterState === 'down'); $('oumta-cluster-state').textContent = clusterState; $('oumta-cluster-updated').textContent = o.updatedAtUtc ? 'as of ' + formatTime(o.updatedAtUtc) : 'no data'; $('oumta-workloads-up').textContent = o.workloadsUp ?? '—'; $('oumta-workloads-down').textContent = o.workloadsDown ?? '—'; $('oumta-last-import').textContent = o.updatedAtUtc ? formatTime(o.updatedAtUtc) : '—'; // Any workload down → the "workloads down" tile becomes an alarm tile. const downCard = $('oumta-workloads-down').closest('.oumta-card'); if (downCard) downCard.classList.toggle('oumta-fail', (o.workloadsDown ?? 0) > 0); // Values derived client-side from the workloads list — the overview // endpoint doesn't carry these today. If the backend later adds // `incidents` / `attention` fields, prefer those over the derivation. const attention = wl.filter(w => w.state !== 'healthy') .sort((a, b) => rankState(a.state) - rankState(b.state)); $('oumta-incidents').textContent = attention.length || '—'; const incCard = $('oumta-incidents').closest('.oumta-card'); if (incCard) incCard.classList.toggle('oumta-fail', attention.length > 0); const list = $('oumta-attention-list'); if (!attention.length) { list.innerHTML = '
Nothing currently flagged.
'; return; } list.innerHTML = attention.map(a => `
${a.state} ${escapeHtml(a.name)} ${escapeHtml(a.namespace)} ${escapeHtml(a.lastReason || '')}
`).join(''); list.querySelectorAll('.oumta-attention-row').forEach(row => { row.addEventListener('click', () => openWorkload(row.dataset.wlid)); }); } // Severity order for the attention list: worst first. function rankState(s) { return { unavailable: 0, degraded: 1, healthy: 2 }[s] ?? 9; } function renderWorkloads() { const search = ($('oumta-wl-search').value || '').toLowerCase(); const filterState = $('oumta-wl-status').value; const body = $('oumta-wl-body'); const rows = (state.data.workloads || []).filter(w => { if (search && !w.name.toLowerCase().includes(search)) return false; if (filterState && w.state !== filterState) return false; return true; }); if (!rows.length) { body.innerHTML = 'No workloads match.'; return; } body.innerHTML = rows.map(w => ` ${escapeHtml(w.name)} ${escapeHtml(w.namespace)} ${w.state} ${w.replicasReady}/${w.replicasDesired} ${escapeHtml(w.cpu)} ${escapeHtml(w.memory)} ${escapeHtml(w.image)} ${escapeHtml(w.lastReason)} `).join(''); body.querySelectorAll('tr').forEach(tr => { tr.addEventListener('click', () => openWorkload(tr.dataset.wlid)); }); } // ── Timeline helpers ────────────────────────────────────────── const TL_CLUSTER_WINDOW_MS = 60_000; // events within 60s collapse into one entry const TL_SEV_RANK = { error: 5, warning: 4, recovered: 3, image: 2, info: 1 }; const TL_MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; /** Normalise an event's severity. Two post-hoc classifications matter: * "-> healthy" transitions are recoveries, and ImageRolled reasons are * real image pushes (vs. plain revision bumps which are just info). */ function eventSev(e) { const isRecovery = typeof e.message === 'string' && e.message.endsWith('-> healthy'); const isImageRoll = e.reason === 'ImageRolled'; if (isRecovery) return 'recovered'; if (isImageRoll) return 'image'; return e.severity || 'info'; } /** Workload short-name for the filter pill (strip namespace prefix). */ function workloadShort(id) { if (!id) return '?'; const parts = id.split('/'); return parts[parts.length - 1] || id; } /** Greedy clustering: walk events (assumed newest-first) and start a new * cluster whenever the gap exceeds the tolerance window. */ function clusterEvents(events) { const clusters = []; let current = null; for (const e of events) { const ts = Date.parse(e.timestamp); if (!current || isNaN(ts) || Math.abs(current.anchorTs - ts) > TL_CLUSTER_WINDOW_MS) { current = { anchorTs: ts, ts: e.timestamp, events: [e] }; clusters.push(current); } else { current.events.push(e); } } return clusters; } function dominantSev(events) { let best = null; for (const e of events) { const sev = eventSev(e); if (!best || TL_SEV_RANK[sev] > TL_SEV_RANK[best]) best = sev; } return best || 'info'; } /** Scan the cluster (events are newest-first) for the most recent state * transition and map it to a dot severity. This reflects how the * workload ended up after the cluster, not the worst thing that * happened inside it — so a cluster that ends in "-> healthy" shows a * green dot even if it contains degraded/warning events along the way. * A cluster without any state transition (rollouts or image changes * that didn't flip state) counts as "uneventful" → also green, since * the absence of a transition means the workload stayed in whatever * state it was in. */ function clusterOutcomeSev(events) { for (const e of events) { const m = /->\s*(\w+)\s*$/.exec(e.message || ''); if (!m) continue; switch (m[1]) { case 'healthy': return 'recovered'; case 'degraded': return 'warning'; case 'unavailable': return 'error'; } } return 'recovered'; } function sameDay(a, b) { const da = new Date(a), db = new Date(b); return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate(); } function formatTimeShort(iso) { const d = new Date(iso); if (isNaN(d.getTime())) return iso; const now = new Date(); if (Math.abs(now - d) < 60_000) return 'just now'; const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); return `${hh}:${mm}`; } function renderTimeline() { const scope = state.data.scopeWorkload; const scopeEl = $('oumta-timeline-scope'); const backEl = $('oumta-timeline-back'); if (scope) { scopeEl.textContent = 'events for ' + scope; backEl.classList.remove('hidden'); } else { const n = state.timelineFilter.size; scopeEl.textContent = n === 0 ? 'all events' : `filtered · ${n}`; backEl.classList.add('hidden'); } const filter = state.timelineFilter; const events = state.data.timeline.filter(e => { if (scope) return e.workloadId === scope; if (filter.size === 0) return true; return filter.has(e.workloadId); }); renderTimelineFilters(); const body = $('oumta-timeline-body'); if (!events.length) { body.innerHTML = '
No events.
'; return; } const clusters = clusterEvents(events); let lastDate = null; body.innerHTML = clusters.map(cluster => { const sev = clusterOutcomeSev(cluster.events) || dominantSev(cluster.events); const showDate = !lastDate || !sameDay(lastDate, cluster.ts); lastDate = cluster.ts; const d = new Date(cluster.ts); const badge = showDate ? `
${d.getDate()} ${TL_MONTHS[d.getMonth()]}
` : ''; const left = ` ${badge}
${escapeHtml(formatTimeShort(cluster.ts))}
`; const right = `
${cluster.events.map(e => { const esev = eventSev(e); const wid = e.workloadId || ''; return `
${esev} ${escapeHtml(workloadShort(wid))} ${escapeHtml(e.reason || '')}
${escapeHtml(e.message || '')}
`; }).join('')}
`; return `
${left}${right}
`; }).join(''); // Tapping a workload name inside an event toggles it in the filter set. body.querySelectorAll('.oumta-tl-workload').forEach(el => { el.addEventListener('click', () => toggleTimelineFilter(el.dataset.wlid)); }); } /** Toggle a workload in the multi-select filter set. Passing an empty * value clears the set (the "all" pill). */ function toggleTimelineFilter(workloadId) { const set = state.timelineFilter; if (!workloadId) { set.clear(); } else if (set.has(workloadId)) { set.delete(workloadId); } else { set.add(workloadId); } renderTimeline(); } /** Render the workload quick-pick bar. "all" + distinct workloads seen in * the current timeline, capped at N inline pills — overflow collapses * into a dropdown. Deduplicated by workloadId, ranked by most recent * event so the pills you care about stay visible. */ function renderTimelineFilters() { const host = $('oumta-timeline-filters'); if (!host) return; // In workload-detail scope (opened via a card click) the filter bar is // redundant — we're already showing one workload's events. if (state.data.scopeWorkload) { host.innerHTML = ''; return; } const seen = new Map(); // workloadId -> earliest-seen index (= recency) state.data.timeline.forEach((e, i) => { if (e.workloadId && !seen.has(e.workloadId)) seen.set(e.workloadId, i); }); const workloads = [...seen.keys()]; if (!workloads.length) { host.innerHTML = ''; return; } const INLINE_LIMIT = 6; const inline = workloads.slice(0, INLINE_LIMIT); const overflow = workloads.slice(INLINE_LIMIT); const active = state.timelineFilter; // Set const pill = (id, label, isActive) => ``; // "all" is active whenever the filter set is empty; picking it clears // the set. The individual pills toggle their workload in the set so // multiple filters can stack (any-of match). const overflowActiveCount = overflow.reduce((n, id) => n + (active.has(id) ? 1 : 0), 0); host.innerHTML = [ pill('', 'all', active.size === 0), ...inline.map(id => pill(id, workloadShort(id), active.has(id))), overflow.length ? `
` : '', ].filter(Boolean).join(''); host.querySelectorAll('.oumta-tl-pill[data-wlid]').forEach(btn => { btn.addEventListener('click', () => toggleTimelineFilter(btn.dataset.wlid || '')); }); const moreBtn = host.querySelector('[data-tl-more]'); const moreMenu = host.querySelector('.oumta-tl-more-menu'); if (moreBtn && moreMenu) { moreBtn.addEventListener('click', (e) => { e.stopPropagation(); moreMenu.classList.toggle('hidden'); }); // Overflow picks don't close the menu — multi-select UX relies on // being able to tick several without the menu collapsing every time. moreMenu.querySelectorAll('button[data-wlid]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); toggleTimelineFilter(btn.dataset.wlid || ''); }); }); document.addEventListener('click', (e) => { if (!moreMenu.contains(e.target) && e.target !== moreBtn) moreMenu.classList.add('hidden'); }, { once: true }); } } // ── Workload → detail-as-timeline navigation ────────────────── async function openWorkload(workloadId) { state.data.scopeWorkload = workloadId; const [nsSlug, name] = workloadId.replace('deployment/', '').split('/'); // Pull workload-scoped timeline — this REPLACES the generic one for rendering. try { const res = await api(`/api/workloads/${nsSlug}/${name}/timeline`); state.data.timeline = res.items || []; } catch (e) { setLive('error', e.message); } switchView('timeline'); } $('oumta-timeline-back')?.addEventListener('click', () => { state.data.scopeWorkload = null; // Restore the global timeline api('/api/timeline').then(r => { state.data.timeline = r.items || []; renderTimeline(); }).catch(e => setLive('error', e.message)); }); // ── Polling loop ────────────────────────────────────────────── // // SSE intentionally unsupported: the PHP proxy buffers whole responses, // so streaming through it would pin an Apache worker per client. If we // ever want live events, bypass the proxy and talk to the Go backend's // WebSocket / SSE endpoint directly — browsers don't enforce CORS on // WS, and SSE would need a short-lived signed URL from the proxy. function startLive() { stopLive(); startPolling(); } function stopLive() { if (state.pollHandle) { clearInterval(state.pollHandle); state.pollHandle = null; } if (state.sseHandle) { state.sseHandle.close(); state.sseHandle = null; } setLive('off'); } function startPolling() { const ms = (CONFIG.pollIntervalSec || 15) * 1000; state.pollHandle = setInterval(refresh, ms); setLive('polling'); } function setLive(mode, detail = '') { state.liveMode = mode; const el = $('oumta-live-indicator'); const lbl = $('oumta-live-label'); if (el) el.dataset.state = mode; if (lbl) { lbl.textContent = ({ live: 'live', polling: 'polling', error: 'error' + (detail ? ' — ' + detail : ''), off: 'off', })[mode] || mode; } } // ── Header buttons ─────────────────────────────────────────── $('oumta-close')?.addEventListener('click', hide); $('oumta-refresh')?.addEventListener('click', refresh); $('oumta-wl-search')?.addEventListener('input', renderWorkloads); $('oumta-wl-status')?.addEventListener('change', renderWorkloads); $('oumta-wl-timerange')?.addEventListener('change', () => { // TODO(api): forward to /api/workloads?since= once the backend accepts it. renderWorkloads(); }); // ESC to close window.addEventListener('keydown', e => { if (e.key === 'Escape' && isOpen()) { e.preventDefault(); e.stopPropagation(); hide(); } }, { capture: true }); // ── Inject header button into hyprcrd top bar ──────────────── function injectHeaderButton() { const tasksBtn = document.getElementById('tasks-btn'); if (!tasksBtn || document.getElementById('oumta-btn')) return; const btn = document.createElement('button'); btn.id = 'oumta-btn'; btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors'; btn.innerHTML = 'Oumta'; btn.addEventListener('click', toggle); tasksBtn.parentNode.insertBefore(btn, tasksBtn); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', injectHeaderButton); } else { injectHeaderButton(); } // ── Helpers ───────────────────────────────────────────────── function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function escapeAttr(s) { return escapeHtml(s); } function formatTime(iso) { try { const d = new Date(iso); return d.toLocaleString(); } catch { return iso; } } // ── Boot ──────────────────────────────────────────────────── h?.registerPlugin?.({ name: 'oumta-dashboard', version: '0.1.0' });