From efd3f9d5ae015e6fe585d82e17d595fa2184e21e Mon Sep 17 00:00:00 2001 From: Torsten Ueberschar Date: Sun, 19 Apr 2026 13:37:06 +0200 Subject: [PATCH] =?UTF-8?q?v2.0.13=20=E2=80=94=20oumta-dashboard=20via=20P?= =?UTF-8?q?HP=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');