Improve Cluster Dashboard Timeline (#1)

Reviewed-on: tanztee/hyprcrd#1
This commit was merged in pull request #1.
This commit is contained in:
tu committed 2026-04-19 13:08:57 +00:00
1 parent 4baeee159e
commit 130a1c660b
6 files changed
+140 -48

No files matched your search

@@ -1,17 +1,16 @@
// ── Oumta Dashboard plugin ──
//
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
// Backend location + poll interval are hardwired below — change them here
// if the deployment topology shifts. No UI settings panel on purpose:
// this plugin only makes sense in one specific deployment, so a settings
// tab would just be noise and tempt accidental misconfiguration.
// 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 = {
baseUrl: 'http://localhost:8085',
authToken: '', // Keycloak bearer; empty = no auth yet
proxyPrefix: '/api/dashboard',
pollIntervalSec: 15,
};
@@ -28,21 +27,19 @@ const state = {
// ── Central API surface ─────────────────────────────────────────
//
// Every backend call routes through `api()`. Requests go against the
// hardwired baseUrl; the active namespace (if any) is attached as a
// `namespace` query parameter, and the bearer token (if any) is sent as
// Authorization. Response shapes are defined by the Go backend in
// 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(path, CONFIG.baseUrl);
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 headers = CONFIG.authToken
? { Authorization: 'Bearer ' + CONFIG.authToken }
: {};
const res = await fetch(url, { ...opts, headers });
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
}
@@ -57,11 +54,12 @@ function isOpen() {
function show() {
if (!overlay) return;
overlay.classList.add('open');
if (!state.namespaces.length) bootstrap();
else {
refresh();
startLive();
}
// 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;
@@ -73,21 +71,6 @@ function toggle() {
}
window._oumtaToggle = toggle;
// ── Bootstrap: load namespaces, then first refresh ─────────────
async function bootstrap() {
try {
const res = await api('/api/namespaces');
state.namespaces = res.items || [];
state.activeNamespace = state.namespaces[0] || null;
renderNamespaceTabs();
await refresh();
startLive();
} catch (e) {
setLive('error', e.message);
}
}
function renderNamespaceTabs() {
const host = $('oumta-ns-tabs');
if (!host) return;
@@ -128,10 +111,27 @@ function switchView(view) {
}
// ── 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() {
if (!state.activeNamespace) return;
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'),
@@ -141,6 +141,7 @@ async function refresh() {
state.data.workloads = wl.items || [];
state.data.timeline = tl.items || [];
renderAll();
setLive('polling');
} catch (e) {
setLive('error', e.message);
}
@@ -289,6 +290,28 @@ function dominantSev(events) {
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()
@@ -335,7 +358,7 @@ function renderTimeline() {
let lastDate = null;
body.innerHTML = clusters.map(cluster => {
const sev = dominantSev(cluster.events);
const sev = clusterOutcomeSev(cluster.events) || dominantSev(cluster.events);
const showDate = !lastDate || !sameDay(lastDate, cluster.ts);
lastDate = cluster.ts;
const d = new Date(cluster.ts);
@@ -475,19 +498,16 @@ $('oumta-timeline-back')?.addEventListener('click', () => {
}).catch(e => setLive('error', e.message));
});
// ── Realtime (SSE) with polling fallback ──────────────────────
// ── Polling loop ──────────────────────────────────────────────
//
// Plan: open an EventSource at <baseUrl>/api/events?namespace=… — backend
// not implemented yet, so we go straight to polling. When SSE is available
// we can try to connect and fall back on `error`.
// 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();
// TODO(api): attempt SSE first:
// state.sseHandle = new EventSource(url);
// state.sseHandle.onmessage = ev => applyLiveEvent(JSON.parse(ev.data));
// state.sseHandle.onerror = () => { stopLive(); startPolling(); setLive('polling'); };
// state.sseHandle.onopen = () => setLive('live');
startPolling();
}
function stopLive() {