v2.0.13 — oumta-dashboard via PHP proxy; quiet support poll

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) <noreply@anthropic.com>
This commit is contained in:
tuandClaude Opus 4.7 committed 2026-04-19 13:37:06 +02:00
1 parent 7bce6a3a36
commit efd3f9d5ae
6 files changed
+92 -26

No files matched your search

+9 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '2.0.12');
define('APP_VERSION', '2.0.13');
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
// Off by default; enable via env for local troubleshooting only.
@@ -18,3 +18,11 @@ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
define('BASE_URL', $protocol . '://' . $host . $basePath);
// Oumta dashboard upstream. The browser only talks to /api/dashboard/*
// same-origin; the PHP proxy below forwards to this URL. Override via env
// for remote deployments. Inside Docker on Linux, localhost points at the
// container itself — use http://host.docker.internal:8085 (needs
// extra_hosts) or a routable URL to reach a host-side dashboard.
define('OUMTA_DASHBOARD_URL', getenv('OUMTA_DASHBOARD_URL') ?: 'http://localhost:8085');
define('OUMTA_DASHBOARD_TOKEN', getenv('OUMTA_DASHBOARD_TOKEN') ?: '');
+9
View File
@@ -3,6 +3,15 @@ services:
build: .
ports:
- "8088:80"
environment:
# `host.docker.internal` resolves to the Docker host via the
# extra_hosts mapping below, so the PHP proxy can reach a dashboard
# running directly on the host. Swap to a routable URL when the
# dashboard moves to a remote server.
OUMTA_DASHBOARD_URL: http://host.docker.internal:8085
# OUMTA_DASHBOARD_TOKEN: "" # Keycloak bearer, when auth is on
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./data:/var/www/html/data
- ./src:/var/www/html/src
+49
View File
@@ -1005,6 +1005,55 @@ if (str_starts_with($route, '/api/')) {
if ($route === '/api/caldav/sync' && $method === 'POST') {
echo json_encode($caldav->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') {
@@ -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); }
@@ -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 <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() {
+6
View File
@@ -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');