Improve Cluster Dashboard Timeline (#1)
Reviewed-on: tanztee/hyprcrd#1
This commit was merged in pull request #1.
This commit is contained in:
1 parent
4baeee159e
commit
130a1c660b
6 files changed
+138
-46
No files matched your search
+9
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '2.0.14');
|
define('APP_VERSION', '2.0.16');
|
||||||
|
|
||||||
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
|
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
|
||||||
// Off by default; enable via env for local troubleshooting only.
|
// Off by default; enable via env for local troubleshooting only.
|
||||||
@@ -18,3 +18,11 @@ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
|||||||
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
|
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
|
||||||
define('BASE_URL', $protocol . '://' . $host . $basePath);
|
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') ?: '');
|
||||||
@@ -3,6 +3,15 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8088:80"
|
- "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:
|
volumes:
|
||||||
- ./data:/var/www/html/data
|
- ./data:/var/www/html/data
|
||||||
- ./src:/var/www/html/src
|
- ./src:/var/www/html/src
|
||||||
|
|||||||
@@ -1005,6 +1005,55 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($route === '/api/caldav/sync' && $method === 'POST') {
|
if ($route === '/api/caldav/sync' && $method === 'POST') {
|
||||||
echo json_encode($caldav->sync()); exit;
|
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 ──
|
// ── Relay ──
|
||||||
|
|
||||||
if ($route === '/api/relay/settings' && $method === 'GET') {
|
if ($route === '/api/relay/settings' && $method === 'GET') {
|
||||||
|
|||||||
@@ -404,5 +404,5 @@
|
|||||||
transition: background 0.2s;
|
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="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); }
|
#oumta-live-indicator[data-state="error"] .oumta-dot { background: var(--t-danger); }
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
// ── Oumta Dashboard plugin ──
|
// ── Oumta Dashboard plugin ──
|
||||||
//
|
//
|
||||||
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
|
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
|
||||||
// Backend location + poll interval are hardwired below — change them here
|
// The browser talks to /api/dashboard/* same-origin; the hyprcrd PHP
|
||||||
// if the deployment topology shifts. No UI settings panel on purpose:
|
// backend forwards to the real upstream (see OUMTA_DASHBOARD_URL in
|
||||||
// this plugin only makes sense in one specific deployment, so a settings
|
// config.php). Keeps CORS out of the browser and lets the dashboard
|
||||||
// tab would just be noise and tempt accidental misconfiguration.
|
// live on a private network reachable only by the hyprcrd container.
|
||||||
|
|
||||||
const h = window.hyprcrd;
|
const h = window.hyprcrd;
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
baseUrl: 'http://localhost:8085',
|
proxyPrefix: '/api/dashboard',
|
||||||
authToken: '', // Keycloak bearer; empty = no auth yet
|
|
||||||
pollIntervalSec: 15,
|
pollIntervalSec: 15,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,21 +27,19 @@ const state = {
|
|||||||
|
|
||||||
// ── Central API surface ─────────────────────────────────────────
|
// ── Central API surface ─────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Every backend call routes through `api()`. Requests go against the
|
// Every backend call routes through `api()` against the same-origin
|
||||||
// hardwired baseUrl; the active namespace (if any) is attached as a
|
// proxy prefix; the active namespace (if any) is attached as a
|
||||||
// `namespace` query parameter, and the bearer token (if any) is sent as
|
// `namespace` query parameter. Auth / bearer tokens live server-side
|
||||||
// Authorization. Response shapes are defined by the Go backend in
|
// in config.php, so nothing sensitive ships to the browser. Response
|
||||||
|
// shapes are defined by the Go backend in
|
||||||
// `internal/httpapi/contracts.go`.
|
// `internal/httpapi/contracts.go`.
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
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) {
|
if (!url.searchParams.has('namespace') && state.activeNamespace) {
|
||||||
url.searchParams.set('namespace', state.activeNamespace.namespace);
|
url.searchParams.set('namespace', state.activeNamespace.namespace);
|
||||||
}
|
}
|
||||||
const headers = CONFIG.authToken
|
const res = await fetch(url, opts);
|
||||||
? { Authorization: 'Bearer ' + CONFIG.authToken }
|
|
||||||
: {};
|
|
||||||
const res = await fetch(url, { ...opts, headers });
|
|
||||||
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -57,12 +54,13 @@ function isOpen() {
|
|||||||
function show() {
|
function show() {
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
if (!state.namespaces.length) bootstrap();
|
// Always kick off polling — refresh() self-heals: it loads namespaces
|
||||||
else {
|
// 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();
|
refresh();
|
||||||
startLive();
|
startLive();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
function hide() {
|
function hide() {
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
overlay.classList.remove('open');
|
overlay.classList.remove('open');
|
||||||
@@ -73,21 +71,6 @@ function toggle() {
|
|||||||
}
|
}
|
||||||
window._oumtaToggle = 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() {
|
function renderNamespaceTabs() {
|
||||||
const host = $('oumta-ns-tabs');
|
const host = $('oumta-ns-tabs');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
@@ -128,10 +111,27 @@ function switchView(view) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Refresh all data for the active namespace ──────────────────
|
// ── 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() {
|
async function refresh() {
|
||||||
if (!state.activeNamespace) return;
|
|
||||||
try {
|
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([
|
const [ov, wl, tl] = await Promise.all([
|
||||||
api('/api/cluster/overview'),
|
api('/api/cluster/overview'),
|
||||||
api('/api/workloads'),
|
api('/api/workloads'),
|
||||||
@@ -141,6 +141,7 @@ async function refresh() {
|
|||||||
state.data.workloads = wl.items || [];
|
state.data.workloads = wl.items || [];
|
||||||
state.data.timeline = tl.items || [];
|
state.data.timeline = tl.items || [];
|
||||||
renderAll();
|
renderAll();
|
||||||
|
setLive('polling');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLive('error', e.message);
|
setLive('error', e.message);
|
||||||
}
|
}
|
||||||
@@ -289,6 +290,28 @@ function dominantSev(events) {
|
|||||||
return best || 'info';
|
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) {
|
function sameDay(a, b) {
|
||||||
const da = new Date(a), db = new Date(b);
|
const da = new Date(a), db = new Date(b);
|
||||||
return da.getFullYear() === db.getFullYear()
|
return da.getFullYear() === db.getFullYear()
|
||||||
@@ -335,7 +358,7 @@ function renderTimeline() {
|
|||||||
|
|
||||||
let lastDate = null;
|
let lastDate = null;
|
||||||
body.innerHTML = clusters.map(cluster => {
|
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);
|
const showDate = !lastDate || !sameDay(lastDate, cluster.ts);
|
||||||
lastDate = cluster.ts;
|
lastDate = cluster.ts;
|
||||||
const d = new Date(cluster.ts);
|
const d = new Date(cluster.ts);
|
||||||
@@ -475,19 +498,16 @@ $('oumta-timeline-back')?.addEventListener('click', () => {
|
|||||||
}).catch(e => setLive('error', e.message));
|
}).catch(e => setLive('error', e.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Realtime (SSE) with polling fallback ──────────────────────
|
// ── Polling loop ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Plan: open an EventSource at <baseUrl>/api/events?namespace=… — backend
|
// SSE intentionally unsupported: the PHP proxy buffers whole responses,
|
||||||
// not implemented yet, so we go straight to polling. When SSE is available
|
// so streaming through it would pin an Apache worker per client. If we
|
||||||
// we can try to connect and fall back on `error`.
|
// 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() {
|
function startLive() {
|
||||||
stopLive();
|
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();
|
startPolling();
|
||||||
}
|
}
|
||||||
function stopLive() {
|
function stopLive() {
|
||||||
|
|||||||
@@ -296,6 +296,12 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/support/poll' && $method === 'POST') {
|
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', '') ?: '';
|
$last = supportSetting('last_polled_at', '') ?: '';
|
||||||
$qs = $last !== '' ? '?since=' . urlencode($last) : '';
|
$qs = $last !== '' ? '?since=' . urlencode($last) : '';
|
||||||
$res = supportCurl('/api/hyprcrd/tickets' . $qs, 'GET');
|
$res = supportCurl('/api/hyprcrd/tickets' . $qs, 'GET');
|
||||||
|
|||||||
Reference in new issue
Block a user