From 130a1c660b9b93a3361e63f1b126f04f39fd001d Mon Sep 17 00:00:00 2001 From: Torsten Ueberschar Date: Sun, 19 Apr 2026 13:08:57 +0000 Subject: [PATCH] Improve Cluster Dashboard Timeline (#1) Reviewed-on: https://git.uesome.de/tanztee/hyprcrd/pulls/1 --- config.php | 10 +- docker-compose.yml | 9 ++ public/index.php | 49 ++++++++ .../oumta-dashboard/oumta-dashboard.css | 2 +- .../oumta-dashboard/oumta-dashboard.js | 112 +++++++++++------- public/plugins/support/backend.php | 6 + 6 files changed, 140 insertions(+), 48 deletions(-) diff --git a/config.php b/config.php index f141c59..155f840 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ sync()); exit; } + // ── Oumta dashboard proxy ── + // Browser talks to /api/dashboard/* same-origin; we forward everything + // after that prefix to OUMTA_DASHBOARD_URL. Keeps CORS out of the + // browser, lets the dashboard live on a private network, and keeps any + // bearer token server-side. No SSE / streaming here: the response is + // buffered whole, so the plugin uses its polling fallback. + if (str_starts_with($route, '/api/dashboard/')) { + $upstreamPath = substr($route, strlen('/api/dashboard')); + $upstream = rtrim(OUMTA_DASHBOARD_URL, '/') . $upstreamPath; + $query = $_SERVER['QUERY_STRING'] ?? ''; + if ($query !== '') $upstream .= '?' . $query; + + $headers = ['Accept: application/json']; + if (OUMTA_DASHBOARD_TOKEN !== '') { + $headers[] = 'Authorization: Bearer ' . OUMTA_DASHBOARD_TOKEN; + } + + $ch = curl_init($upstream); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_TIMEOUT => 15, + ]); + if ($method !== 'GET' && $method !== 'HEAD') { + $body = file_get_contents('php://input'); + if ($body !== '' && $body !== false) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + $headers[] = 'Content-Type: ' . ($_SERVER['CONTENT_TYPE'] ?? 'application/json'); + } + } + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + hyprcrdApplySafeCurlOpts($ch); + $resp = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $ct = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $err = curl_error($ch); + curl_close($ch); + + if ($status === 0) { + http_response_code(502); + echo json_encode(['error' => 'dashboard upstream unreachable', 'detail' => APP_DEBUG ? $err : '']); + exit; + } + http_response_code($status); + if ($ct) header('Content-Type: ' . $ct); + echo $resp; + exit; + } + // ── Relay ── if ($route === '/api/relay/settings' && $method === 'GET') { diff --git a/public/plugins/oumta-dashboard/oumta-dashboard.css b/public/plugins/oumta-dashboard/oumta-dashboard.css index 68ec9dc..09b9d64 100644 --- a/public/plugins/oumta-dashboard/oumta-dashboard.css +++ b/public/plugins/oumta-dashboard/oumta-dashboard.css @@ -404,5 +404,5 @@ transition: background 0.2s; } #oumta-live-indicator[data-state="live"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; } -#oumta-live-indicator[data-state="polling"] .oumta-dot { background: #fb923c; } +#oumta-live-indicator[data-state="polling"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; } #oumta-live-indicator[data-state="error"] .oumta-dot { background: var(--t-danger); } diff --git a/public/plugins/oumta-dashboard/oumta-dashboard.js b/public/plugins/oumta-dashboard/oumta-dashboard.js index f1197f2..ab1a736 100644 --- a/public/plugins/oumta-dashboard/oumta-dashboard.js +++ b/public/plugins/oumta-dashboard/oumta-dashboard.js @@ -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 /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() { diff --git a/public/plugins/support/backend.php b/public/plugins/support/backend.php index ecc74d5..46266f1 100644 --- a/public/plugins/support/backend.php +++ b/public/plugins/support/backend.php @@ -296,6 +296,12 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { } if ($route === '/api/support/poll' && $method === 'POST') { + // Mirrors /api/relay/poll: an unconfigured support relay is a normal + // resting state, not an error. Returning 502 here floods the network + // tab on every background poll for users who don't use the plugin. + if (supportRelayUrl() === '' || supportRelayKey() === '') { + supportJson(['ok' => true, 'pulled' => 0]); + } $last = supportSetting('last_polled_at', '') ?: ''; $qs = $last !== '' ? '?since=' . urlencode($last) : ''; $res = supportCurl('/api/hyprcrd/tickets' . $qs, 'GET');