From efd3f9d5ae015e6fe585d82e17d595fa2184e21e Mon Sep 17 00:00:00 2001 From: Torsten Ueberschar Date: Sun, 19 Apr 2026 13:37:06 +0200 Subject: [PATCH 1/4] =?UTF-8?q?v2.0.13=20=E2=80=94=20oumta-dashboard=20via?= =?UTF-8?q?=20PHP=20proxy;=20quiet=20support=20poll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard now reachable across the internet: browser calls /api/dashboard/* same-origin, PHP forwards to OUMTA_DASHBOARD_URL (configurable via env, with docker-compose defaults pointing at host.docker.internal:8085). Keeps the bearer token server-side and lets the dashboard live on a private network. Plumbing bits: polling indicator now green (no reason to look anxious about a healthy poll loop), and the support plugin returns 200 pulled=0 when its relay isn't configured instead of flooding the network tab with 502s on every background tick. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.php | 10 +++- docker-compose.yml | 9 ++++ public/index.php | 49 +++++++++++++++++++ .../oumta-dashboard/oumta-dashboard.css | 2 +- .../oumta-dashboard/oumta-dashboard.js | 42 +++++++--------- public/plugins/support/backend.php | 6 +++ 6 files changed, 92 insertions(+), 26 deletions(-) diff --git a/config.php b/config.php index 3ff8a15..0999315 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..c9cb52f 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(); } @@ -475,19 +472,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'); -- 2.54.0 From cdfb6d4ecd2ddba5dff4a28acaf0c53b6157654d Mon Sep 17 00:00:00 2001 From: Torsten Ueberschar Date: Sun, 19 Apr 2026 13:46:18 +0200 Subject: [PATCH 2/4] =?UTF-8?q?v2.0.14=20=E2=80=94=20oumta-dashboard:=20se?= =?UTF-8?q?lf-healing=20poll=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial-bootstrap failure stopped the poll interval from ever starting, and even once running, refresh() never reset the indicator back to 'polling' on success — so a single upstream blip pinned the plugin in an error state until the user reloaded the page. refresh() now loads namespaces on demand (absorbs bootstrap), the interval is started unconditionally on show(), and every successful poll flips the indicator back to green. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.php | 2 +- .../oumta-dashboard/oumta-dashboard.js | 46 ++++++++++--------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/config.php b/config.php index 0999315..d51e600 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ Date: Sun, 19 Apr 2026 14:25:38 +0200 Subject: [PATCH 3/4] =?UTF-8?q?v2.0.15=20=E2=80=94=20oumta-dashboard:=20do?= =?UTF-8?q?t=20color=20follows=20cluster=20outcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-cluster dot now reflects how the workload ended up after the cluster's events, not the worst thing that happened inside it: if the most recent state transition in the cluster was "-> healthy", the dot turns green even when the cluster contains earlier degraded/warning events. Falls back to the existing dominantSev logic for clusters without any state transition (e.g. rollout-only clusters). Matches the reality that a rollout which briefly degrades and then recovers is a normal, healthy outcome — not a "warning" forever. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.php | 2 +- .../oumta-dashboard/oumta-dashboard.js | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/config.php b/config.php index d51e600..4c5c3bc 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ healthy" shows a + * green dot even if it contains degraded/warning events along the way. + * Returns null when no state-change event is present; callers fall + * back to dominantSev in that case (e.g. rollout-only clusters). */ +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 null; +} + function sameDay(a, b) { const da = new Date(a), db = new Date(b); return da.getFullYear() === db.getFullYear() @@ -336,7 +356,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); -- 2.54.0 From 985cd39b2a28292058b731280c0b10ed6c0948f0 Mon Sep 17 00:00:00 2001 From: Torsten Ueberschar Date: Sun, 19 Apr 2026 14:40:22 +0200 Subject: [PATCH 4/4] =?UTF-8?q?v2.0.16=20=E2=80=94=20oumta-dashboard:=20gr?= =?UTF-8?q?een=20dot=20for=20rollout-only=20clusters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clusters with no state-transition events (e.g. a clean rollout that didn't cause a degradation) now show a green dot instead of blue. Rationale: absence of a transition means the workload stayed in its existing state — the rollout went through unscathed, which is the healthy outcome a reader is scanning for. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.php | 2 +- public/plugins/oumta-dashboard/oumta-dashboard.js | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/config.php b/config.php index 4c5c3bc..155f840 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ healthy" shows a * green dot even if it contains degraded/warning events along the way. - * Returns null when no state-change event is present; callers fall - * back to dominantSev in that case (e.g. rollout-only clusters). */ + * 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 || ''); @@ -307,7 +309,7 @@ function clusterOutcomeSev(events) { case 'unavailable': return 'error'; } } - return null; + return 'recovered'; } function sameDay(a, b) { -- 2.54.0