Merge pull request #629 from CyberMind-FR/feature/628-hub-health-monitor-page-vital-common-ser

hub: Health Monitor page (vital + common services, live) (closes #628)
This commit is contained in:
CyberMind 2026-06-17 10:00:26 +02:00 committed by GitHub
commit 206157047e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 211 additions and 0 deletions

View File

@ -1,3 +1,11 @@
secubox-hub (1.4.5-1~bookworm1) bookworm; urgency=medium
* feat(health): Health Monitor page (/health/) — live status of vital +
common services from public/health-batch, grouped + severity-sorted,
auto-refresh, dark hybrid-skin. Sidebar menu entry (closes #628).
-- Gerald Kerma <devel@cybermind.fr> Tue, 17 Jun 2026 10:00:00 +0200
secubox-hub (1.4.4-1~bookworm1) bookworm; urgency=medium
* fix(perf): dashboard/services cache never warmed under the aggregator,

View File

@ -0,0 +1,9 @@
{
"id": "health",
"name": "Health Monitor",
"category": "root",
"icon": "💚",
"path": "/health/",
"order": 5,
"description": "Live status of vital and common SecuBox services"
}

View File

@ -0,0 +1,51 @@
/* SPDX-License-Identifier: LicenseRef-CMSD-1.0
SecuBox-Deb :: Hub Health Monitor dark hybrid-skin styles */
:root {
--gmb-h: 48px; --sp-s: 8px; --sp-m: 16px; --sp-l: 24px; --sp-xl: 40px; --r-card: 14px;
--bg0: var(--bg-dark, #0A0E14); --bg1: var(--surface-dark, #141A24);
--bd: var(--border-dark, #2A3444); --tx: var(--text-dark, #E8E6E0); --mut: var(--muted-dark, #8A9AA8);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-body, 'Space Grotesk', system-ui, sans-serif); font-size: 14px;
line-height: 1.6; background: var(--bg0); color: var(--tx); min-height: 100vh; }
.main { margin-left: 220px; padding: calc(var(--gmb-h) + var(--sp-l)) var(--sp-l) var(--sp-xl); max-width: 1280px; }
@media (max-width: 768px) { .main { margin-left: 0; padding: calc(var(--gmb-h) + var(--sp-m)) var(--sp-m) var(--sp-l); } }
header.page { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-m);
margin-bottom: var(--sp-l); padding-bottom: var(--sp-m); border-bottom: 1px solid var(--bd); }
header.page h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.4px; color: var(--root-light, #148C66); }
header.page .ver { font-family: var(--font-mono, monospace); font-size: 12px; color: var(--mut); margin-left: var(--sp-s); }
.actions { display: flex; gap: var(--sp-m); align-items: center; }
.updated { font-family: var(--font-mono, monospace); font-size: 11px; color: var(--mut); }
.btn { background: var(--bg1); color: var(--tx); border: 1px solid var(--bd); border-radius: 6px;
padding: 6px 12px; cursor: pointer; font: inherit; font-size: 13px; }
.btn:hover { border-color: var(--root-light, #148C66); }
h2 { font-size: 16px; font-weight: 600; margin: var(--sp-xl) 0 var(--sp-m); }
.count { font-family: var(--font-mono, monospace); font-size: 12px; color: var(--mut); font-weight: 400; }
.banner { padding: var(--sp-m); border-radius: 8px; margin-bottom: var(--sp-m); font-size: 13px; }
.banner.info { background: var(--bg1); color: var(--mut); border: 1px solid var(--bd); }
.banner.err { background: rgba(192,64,64,.12); color: #E8845A; border: 1px solid #803018; }
/* Summary */
.summary { display: flex; gap: var(--sp-m); flex-wrap: wrap; }
.sum { flex: 1; min-width: 120px; background: var(--bg1); border: 1px solid var(--bd);
border-radius: var(--r-card); padding: var(--sp-m); text-align: center; }
.sum b { display: block; font-size: 2rem; font-weight: 700; font-family: var(--font-mono, monospace); line-height: 1; }
.sum span { font-size: 12px; color: var(--mut); }
.sum.ok b { color: #2ecc8f; } .sum.warn b { color: #f0b94c; } .sum.err b { color: #ff7a6b; } .sum.total b { color: var(--tx); }
.sum.ok { border-left: 3px solid #0A5840; } .sum.warn { border-left: 3px solid #9A6010; } .sum.err { border-left: 3px solid #803018; }
/* Service grid */
.svc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--sp-s); }
.svc { display: flex; align-items: center; gap: var(--sp-s); background: var(--bg1); border: 1px solid var(--bd);
border-radius: 8px; padding: 10px 12px; min-width: 0; }
.svc .led { width: 9px; height: 9px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; }
.svc.ok .led { background: #2ecc8f; color: #2ecc8f; }
.svc.warn .led, .svc.unknown .led { background: #f0b94c; color: #f0b94c; }
.svc.error .led { background: #ff7a6b; color: #ff7a6b; animation: pulse 1.2s infinite; }
@keyframes pulse { 50% { opacity: .4; } }
.svc.error { border-left: 3px solid #803018; }
.svc-name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.svc-msg { margin-left: auto; font-size: 11px; color: var(--mut); font-family: var(--font-mono, monospace);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 45%; }
.empty { color: var(--mut); font-size: 13px; padding: var(--sp-m); }

View File

@ -0,0 +1,89 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
// SecuBox-Deb :: Hub Health Monitor — CyberMind https://cybermind.fr
(function () {
'use strict';
const BATCH = '/api/v1/hub/public/health-batch';
const INFO = '/api/v1/hub/public/info';
const REFRESH_MS = 15000;
// Vital services — the security/serving spine; everything else is "common".
const VITAL = ['waf', 'crowdsec', 'mitmproxy', 'haproxy', 'aggregator', 'hub',
'system', 'vortex-dns', 'dns-guard', 'certs', 'wireguard', 'soc',
'metrics', 'core'];
const VITAL_SET = new Set(VITAL);
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
async function getJSON(u) {
const r = await fetch(u, { headers: { 'Accept': 'application/json' }, cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
function chip(id, st) {
const status = (st && st.status) || 'unknown';
const msg = (st && st.msg) || '';
return `<div class="svc ${status}" title="${esc(id)}: ${esc(msg)}">
<span class="led"></span>
<span class="svc-name">${esc(id)}</span>
<span class="svc-msg">${esc(msg)}</span>
</div>`;
}
function render(modules) {
const ids = Object.keys(modules).sort();
let ok = 0, warn = 0, err = 0;
ids.forEach((id) => {
const s = (modules[id] || {}).status;
if (s === 'ok') ok++; else if (s === 'error') err++; else warn++;
});
$('summary').innerHTML =
`<div class="sum ok"><b>${ok}</b><span>healthy</span></div>` +
`<div class="sum warn"><b>${warn}</b><span>degraded</span></div>` +
`<div class="sum err"><b>${err}</b><span>down</span></div>` +
`<div class="sum total"><b>${ids.length}</b><span>services</span></div>`;
const vital = ids.filter((id) => VITAL_SET.has(id));
const common = ids.filter((id) => !VITAL_SET.has(id));
// Sort each: errors first, then warn, then ok, so problems surface.
const rank = (id) => ({ error: 0, warn: 1, unknown: 1, ok: 2 }[(modules[id] || {}).status] ?? 1);
const bySeverity = (a, b) => rank(a) - rank(b) || a.localeCompare(b);
$('vital').innerHTML = vital.sort(bySeverity).map((id) => chip(id, modules[id])).join('')
|| '<div class="empty">no vital services reported</div>';
$('common').innerHTML = common.sort(bySeverity).map((id) => chip(id, modules[id])).join('')
|| '<div class="empty">none</div>';
$('vitalCount').textContent = '(' + vital.length + ')';
$('commonCount').textContent = '(' + common.length + ')';
}
async function load() {
try {
const batch = await getJSON(BATCH);
// health-batch returns {modules: {id: {status,msg}}, count: N}
const modules = (batch && batch.modules) || batch || {};
render(modules);
$('updated').textContent = 'updated ' + new Date().toLocaleTimeString();
$('loading').hidden = true; $('error').hidden = true; $('content').hidden = false;
getJSON(INFO).then((i) => {
if (i && i.hostname) $('ver').textContent = esc(i.hostname);
}).catch(() => {});
} catch (e) {
$('error').hidden = false;
$('error').textContent = 'Could not load health: ' + e.message;
$('loading').hidden = true;
}
}
document.addEventListener('DOMContentLoaded', () => {
$('refresh').addEventListener('click', load);
load();
setInterval(load, REFRESH_MS);
});
})();

View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<!--
SecuBox · Health Monitor — live status of vital + common services.
Consumes /api/v1/hub/public/health-batch. Dark hybrid-skin via /shared/sidebar.js.
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<title>SecuBox · Health Monitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/design-tokens.css">
<link rel="stylesheet" href="/shared/sidebar.css">
<link rel="stylesheet" href="health.css">
</head>
<body class="module-root">
<nav class="sidebar" id="sidebar"></nav>
<main class="main">
<header class="page">
<h1>💚 Health Monitor <span class="ver" id="ver">v1.0</span></h1>
<div class="actions">
<span class="updated" id="updated"></span>
<button class="btn" id="refresh" type="button">↻ Refresh</button>
</div>
</header>
<div id="error" class="banner err" hidden></div>
<div id="loading" class="banner info">Loading service health…</div>
<div id="content" hidden>
<!-- Summary strip -->
<section class="summary" id="summary"></section>
<!-- Vital services -->
<section>
<h2>Vital services <span class="count" id="vitalCount"></span></h2>
<div class="svc-grid" id="vital"></div>
</section>
<!-- Common services -->
<section>
<h2>Common services <span class="count" id="commonCount"></span></h2>
<div class="svc-grid" id="common"></div>
</section>
</div>
</main>
<script src="/shared/sidebar.js"></script>
<script src="health.js" defer></script>
</body>
</html>