build(annuaire): package fleet panel + metrics-publish timer + sbx-fleetctl install + /fleet routing (fleet-metrics)

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-27 08:46:33 +02:00
parent e5ffe91cd0
commit 9bfcbe5d9c
11 changed files with 637 additions and 0 deletions

View File

@ -1,3 +1,21 @@
secubox-annuaire (0.10.0-1~bookworm1) bookworm; urgency=medium
* feat(fleet): fleet metrics — every node signs a MetricSnapshot
(CPU/mem/disk/load1/uptime/modules_down/counters) with its own sovereign
node key and publishes it via secubox-metrics-publish.timer (60s,
User=secubox, sbx-fleetctl publish) into a dedicated last-writer-wins
store (/var/lib/secubox/annuaire/fleet/self.json). Peers pull it
verbatim over the existing :8799 mesh listener (GET /fleet/self, public
— mirrors /log/export); GET /fleet (JWT) aggregates self + verified
peers with health/stale annotations. Ships the /fleet panel (matrix of
nodes, health dot, cpu/mem/disk bars, "vu il y a Ns", XSS-strict:
createElement/textContent only, no innerHTML, no inline handlers) and
the menu.d/595-fleet.json entry. `sbx-fleetctl` now actually installs to
/usr/sbin (fixes the prior task's install gap); [metrics] fleet_publish
documented in secubox.conf.example as the per-box opt-out.
-- Gerald KERMA <devel@cybermind.fr> Mon, 27 Jul 2026 11:00:00 +0200
secubox-annuaire (0.9.0-1~bookworm1) bookworm; urgency=high
* merge: unify the annuaire trust substrate — the assist marketplace layer

View File

@ -6,6 +6,12 @@ case "$1" in
install -d -m 0755 /var/lib/secubox/annuaire /var/log/secubox/annuaire 2>/dev/null || true
chown -R secubox:secubox /var/lib/secubox/annuaire /var/log/secubox/annuaire 2>/dev/null || true
# Fleet metrics store (fleet-metrics, Task 5): secubox-metrics-publish.timer
# runs sbx-fleetctl publish as User=secubox, so this specific leaf dir
# (never the shared /var/lib/secubox parent) must be secubox-owned for
# it to write self.json.
install -d -o secubox -g secubox -m 0755 /var/lib/secubox/annuaire/fleet 2>/dev/null || true
# Node-key secret dir (annuairectl init writes node.key here, 0600 secubox).
# CSPN: secrets under /etc/secubox/secrets/, 0700, owned by the service user.
install -d -m 0700 /etc/secubox/secrets/annuaire 2>/dev/null || true
@ -98,6 +104,11 @@ case "$1" in
# The WAF hook: bridge local crowdsec bans into the mesh. Safe no-op
# where crowdsec is absent, so enabling by default is fine.
systemctl enable --now secubox-threatmesh-bridge.timer 2>/dev/null || true
# Fleet metrics publish (fleet-metrics, Task 5): signs + persists this
# box's own MetricSnapshot every 60s. [metrics] fleet_publish=false in
# secubox.conf makes sbx-fleetctl publish a documented no-op, so
# enabling the timer by default is safe.
systemctl enable --now secubox-metrics-publish.timer 2>/dev/null || true
if systemctl is-active --quiet nginx 2>/dev/null; then
nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true
fi

View File

@ -16,6 +16,7 @@ override_dh_auto_install:
install -d $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox.d
install -m 644 $(CURDIR)/nginx/annuaire.conf $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox.d/
install -m 644 $(CURDIR)/nginx/centers.conf $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox.d/
install -m 644 $(CURDIR)/nginx/fleet.conf $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox.d/
# Authoritative API route (secubox-routes.d)
install -d $(CURDIR)/debian/secubox-annuaire/etc/nginx/secubox-routes.d
@ -32,6 +33,9 @@ override_dh_auto_install:
# Box operator CLI — delegated center grants (Centres & Grants, Task 7)
install -m 755 $(CURDIR)/sbin/sbx-centersctl $(CURDIR)/debian/secubox-annuaire/usr/sbin/sbx-centersctl
# Box operator CLI — fleet metrics publish (fleet-metrics, Task 5)
install -m 755 $(CURDIR)/sbin/sbx-fleetctl $(CURDIR)/debian/secubox-annuaire/usr/sbin/sbx-fleetctl
# Non-local bind sysctl (nginx binds the wg-mesh IP even if iface is late)
install -d $(CURDIR)/debian/secubox-annuaire/etc/sysctl.d
install -m 644 $(CURDIR)/sysctl/30-secubox-nonlocal-bind.conf $(CURDIR)/debian/secubox-annuaire/etc/sysctl.d/
@ -44,10 +48,15 @@ override_dh_auto_install:
install -d $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/www/centers
cp -r $(CURDIR)/www/centers/* $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/www/centers/
# Web UI (static) — Flotte / fleet metrics panel (fleet-metrics, Task 5)
install -d $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/www/fleet
install -m 644 $(CURDIR)/www/fleet/* $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/www/fleet/
# Hub menu entry
install -d $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/menu.d
install -m 644 $(CURDIR)/menu.d/575-annuaire.json $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/menu.d/
install -m 644 $(CURDIR)/menu.d/570-centers.json $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/menu.d/
install -m 644 $(CURDIR)/menu.d/595-fleet.json $(CURDIR)/debian/secubox-annuaire/usr/share/secubox/menu.d/
# systemd service
install -d $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system
@ -63,6 +72,9 @@ override_dh_auto_install:
install -m 755 $(CURDIR)/sbin/sbx-threatmesh-bridge $(CURDIR)/debian/secubox-annuaire/usr/sbin/sbx-threatmesh-bridge
install -m 644 $(CURDIR)/systemd/secubox-threatmesh-bridge.service $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system/
install -m 644 $(CURDIR)/systemd/secubox-threatmesh-bridge.timer $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system/
# Fleet metrics publish timer + oneshot (fleet-metrics, Task 5)
install -m 644 $(CURDIR)/systemd/secubox-metrics-publish.service $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system/
install -m 644 $(CURDIR)/systemd/secubox-metrics-publish.timer $(CURDIR)/debian/secubox-annuaire/usr/lib/systemd/system/
install -d $(CURDIR)/debian/secubox-annuaire/etc/secubox
install -m 644 $(CURDIR)/conf/annuaire-apply.conf.example $(CURDIR)/debian/secubox-annuaire/etc/secubox/

View File

@ -0,0 +1,10 @@
{
"id": "fleet",
"name": "Flotte",
"icon": "🛰",
"path": "/fleet/",
"category": "mesh",
"order": 595,
"description": "Vue mesh des instantanés de métriques signés par nœud — CPU/mém/disque/load, modules down, compteurs",
"requires": ["secubox-annuaire"]
}

View File

@ -0,0 +1,12 @@
# /etc/nginx/secubox.d/fleet.conf — Installed by secubox-annuaire.
# Static "Flotte" UI (Task 5, fleet-metrics). It calls the same
# /api/v1/annuaire/ API prefix as www/annuaire/ and www/centers/ (routed by
# secubox-routes.d/annuaire-routes.conf, already installed) — no separate
# API route needed here; /fleet/self and /fleet ride the existing annuaire
# API mount.
location /fleet/ {
alias /usr/share/secubox/www/fleet/;
index index.html;
try_files $uri $uri/ /fleet/index.html;
}

View File

@ -0,0 +1,13 @@
[Unit]
Description=SecuBox Annuaire — publish this node's signed fleet metrics snapshot (fleet-metrics)
Documentation=file:///usr/share/doc/secubox-annuaire/README
After=secubox-annuaire.service
Wants=secubox-annuaire.service
[Service]
Type=oneshot
User=secubox
Group=secubox
# Signs with the box's own node.key (owned by secubox) — never root. A
# missing/disabled config degrades to a no-op (see sbx-fleetctl --help).
ExecStart=/usr/sbin/sbx-fleetctl publish

View File

@ -0,0 +1,11 @@
[Unit]
Description=SecuBox Annuaire — periodic fleet metrics publish (fleet-metrics)
[Timer]
OnBootSec=90s
OnUnitActiveSec=60s
RandomizedDelaySec=10s
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,122 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: secubox-annuaire :: tests/test_fleet_menu.py
Menu.d entry + /fleet panel coverage (Task 5, feat/fleet-metrics).
The panel is XSS-STRICT (stricter than sibling panels like www/centers):
NO inline on*= handlers (event delegation only) AND NO innerHTML anywhere
for API-sourced data rows are built with createElement/textContent/dataset
only. Mirrors the skin (hybrid-dark, sidebar.js, sbx_token) of
www/centers/index.html.
"""
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MENU_PATH = ROOT / "menu.d" / "595-fleet.json"
HTML_PATH = ROOT / "www" / "fleet" / "index.html"
VALID_CATEGORIES = {"auth", "wall", "boot", "mind", "root", "mesh"}
def _html() -> str:
return HTML_PATH.read_text()
# ── menu.d/595-fleet.json ────────────────────────────────────────────────
def test_menu_valid():
m = json.loads(MENU_PATH.read_text())
assert m.get("category") in VALID_CATEGORIES
assert "/fleet" in json.dumps(m)
def test_menu_has_name_and_category_keys():
m = json.loads(MENU_PATH.read_text())
assert m.get("name")
assert m.get("category") == "mesh"
def test_menu_path_is_fleet():
m = json.loads(MENU_PATH.read_text())
assert m.get("path") == "/fleet/"
# ── panel skin / navbar ──────────────────────────────────────────────────
def test_panel_uses_shared_skin_and_sidebar():
html = _html()
assert '<nav class="sidebar" id="sidebar">' in html
assert '/shared/sidebar.js' in html
assert 'hybrid-dark' in html
def test_panel_uses_sbx_token():
html = _html()
assert "sbx_token" in html
assert "localStorage.getItem('jwt_token')" not in html
assert "localStorage.getItem('token')" not in html
def test_panel_calls_fleet_api():
html = _html()
assert "/api/v1/annuaire" in html
assert "'/fleet'" in html or '"/fleet"' in html
# ── XSS-STRICT: no inline handlers, no innerHTML for API data ───────────
def test_no_inline_event_handlers():
html = _html()
stripped = re.sub(r"<!--.*?-->", "", html, flags=re.S)
stripped = re.sub(r"(?<!:)//[^\n]*", "", stripped)
offenders = re.findall(r'\son\w+\s*=\s*"', stripped)
assert offenders == [], f"inline event handler(s) found: {offenders}"
def test_no_innerhtml_anywhere():
"""Stricter than sibling panels: fleet rows must be built with
createElement/textContent/dataset, never innerHTML, even escaped."""
html = _html()
assert "innerHTML" not in html
def test_uses_create_element_and_text_content():
html = _html()
assert "createElement" in html
assert "textContent" in html
def test_uses_event_delegation_with_dataset():
html = _html()
assert "dataset" in html
assert "addEventListener('click'" in html or 'addEventListener("click"' in html
# ── health / staleness rendering ──────────────────────────────────────────
def test_health_states_represented():
html = _html()
for state in ("down", "warn", "ok"):
assert state in html
def test_stale_handling_present():
html = _html()
assert "stale" in html
def test_refresh_interval_around_10s():
html = _html()
assert "10000" in html or "10_000" in html
# ── graceful degradation ─────────────────────────────────────────────────
def test_fetch_is_guarded_with_try_catch():
html = _html()
assert "try {" in html and "catch" in html

View File

@ -0,0 +1,103 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: secubox-annuaire :: tests/test_fleet_packaging.py
Packaging coverage for fleet metrics (Task 5, feat/fleet-metrics):
the publisher systemd unit/timer, debian/rules install lines, and
debian/postinst (fleet data dir ownership + no shared-parent chown).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
# ── publisher unit / timer ───────────────────────────────────────────────
def test_publisher_unit_non_root_oneshot():
svc = (ROOT / "systemd" / "secubox-metrics-publish.service").read_text()
assert "User=secubox" in svc
assert "sbx-fleetctl publish" in svc
assert "Type=oneshot" in svc
def test_publisher_timer_schedule():
tmr = (ROOT / "systemd" / "secubox-metrics-publish.timer").read_text()
assert "OnUnitActiveSec" in tmr
assert "OnBootSec=90s" in tmr
assert "OnUnitActiveSec=60s" in tmr
assert "WantedBy=timers.target" in tmr
# ── debian/postinst ───────────────────────────────────────────────────────
def test_postinst_no_shared_parent_chown_and_fleetdir():
p = (ROOT / "debian" / "postinst").read_text()
for bad in (
"chown -R secubox /run/secubox",
"chown -R secubox /etc/secubox",
"chown -R secubox /var/lib/secubox",
):
assert bad not in p, f"forbidden shared-parent chown found: {bad!r}"
assert "/var/lib/secubox/annuaire/fleet" in p
assert "#DEBHELPER#" in p
def test_postinst_enables_metrics_publish_timer():
p = (ROOT / "debian" / "postinst").read_text()
assert "secubox-metrics-publish.timer" in p
assert "daemon-reload" in p
def test_debhelper_token_alone_on_its_line():
p = (ROOT / "debian" / "postinst").read_text()
lines = [l.strip() for l in p.splitlines()]
assert "#DEBHELPER#" in lines
# ── debian/rules install lines ───────────────────────────────────────────
def test_rules_installs_sbx_fleetctl_to_usr_sbin():
rules = (ROOT / "debian" / "rules").read_text()
assert "sbin/sbx-fleetctl" in rules
assert "usr/sbin" in rules
def test_rules_installs_metrics_publish_units():
rules = (ROOT / "debian" / "rules").read_text()
assert "secubox-metrics-publish.service" in rules
assert "secubox-metrics-publish.timer" in rules
assert "lib/systemd/system" in rules
def test_rules_installs_www_fleet():
rules = (ROOT / "debian" / "rules").read_text()
assert "www/fleet" in rules
def test_rules_installs_menu_and_nginx():
rules = (ROOT / "debian" / "rules").read_text()
assert "595-fleet.json" in rules
assert "fleet.conf" in rules
def test_annuaire_source_modules_exist_on_disk():
"""fleet.py/fleet_store.py/metrics_collect.py already ship via the
existing `cp -r annuaire/*` glob in override_dh_auto_install verify
the source files are actually present so that glob has something to copy."""
for mod in ("fleet.py", "fleet_store.py", "metrics_collect.py"):
assert (ROOT / "annuaire" / mod).exists(), f"missing annuaire/{mod}"
# ── changelog ─────────────────────────────────────────────────────────────
def test_changelog_bumped_for_fleet_metrics():
head = (ROOT / "debian" / "changelog").read_text().splitlines()[0]
assert "secubox-annuaire (" in head
# version must be strictly newer than the pre-task-5 0.9.0
import re
m = re.search(r"\((\d+)\.(\d+)\.(\d+)-", head)
assert m, f"unparseable changelog head: {head!r}"
major, minor, patch = (int(x) for x in m.groups())
assert (major, minor, patch) > (0, 9, 0)

View File

@ -0,0 +1,309 @@
<!DOCTYPE html>
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
<html lang="fr" class="hybrid-dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SecuBox — Flotte</title>
<link rel="stylesheet" href="/shared/hybrid-dark.css">
<style>
:root{
--p31-dim:#6b7b8b; --bg-dark:#0d1117; --bg-card:rgba(30,40,55,.8);
--bg-row:rgba(20,30,45,.6); --border:rgba(100,150,200,.2); --text:#e8e6d9;
--cyan:#00d4ff; --red:#ff4466; --green:#00dd44; --yellow:#ffcc00;
}
body{font-family:'Courier Prime',monospace;color:var(--text);background:var(--bg-dark)}
.header{display:flex;justify-content:space-between;align-items:center;gap:.6rem;flex-wrap:wrap;margin-bottom:1rem}
.header h1{font-size:1.3rem;color:var(--cyan);text-shadow:0 0 10px var(--cyan);margin:0}
.header .sub{font-size:.72rem;color:var(--p31-dim);margin-top:.2rem}
.dot{display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:var(--p31-dim);margin-right:.35rem;vertical-align:middle}
.dot.on{background:var(--green);box-shadow:0 0 6px var(--green)} .dot.off{background:var(--red)}
.dot.ok{background:var(--green);box-shadow:0 0 6px var(--green)}
.dot.warn{background:var(--yellow);box-shadow:0 0 6px var(--yellow)}
.dot.down{background:var(--red);box-shadow:0 0 6px var(--red)}
.dot.stale{background:var(--p31-dim);box-shadow:none}
.btns{display:flex;flex-wrap:wrap;gap:.4rem}
.btn{font-family:inherit;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;text-decoration:none;
color:var(--text);background:var(--bg-row);border:1px solid var(--border);border-radius:6px;
padding:.45rem .8rem;cursor:pointer;transition:border-color .15s,box-shadow .15s}
.btn:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,.4)}
.btn.small{padding:.2rem .45rem;font-size:.62rem}
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem;backdrop-filter:blur(10px)}
.card h3{font-size:.8rem;color:var(--cyan);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.6rem}
.note{font-size:.76rem;color:var(--p31-dim);line-height:1.5;margin-top:.5rem}
table.matrix{width:100%;border-collapse:collapse;font-size:.78rem}
table.matrix th,table.matrix td{padding:.5rem .6rem;border-bottom:1px solid var(--border);text-align:left;vertical-align:middle}
table.matrix th{color:var(--p31-dim);text-transform:uppercase;font-size:.66rem;letter-spacing:.06em}
.empty-cell{color:var(--p31-dim);font-style:italic;font-size:.74rem}
tr.node-row.is-stale{opacity:.45}
.bar-wrap{display:inline-block;width:5rem;height:.5rem;background:var(--bg-row);border:1px solid var(--border);border-radius:3px;overflow:hidden;vertical-align:middle;margin-right:.4rem}
.bar-fill{display:block;height:100%;background:var(--cyan)}
.bar-fill.warn{background:var(--yellow)}
.bar-fill.crit{background:var(--red)}
.cell-inner{display:flex;align-items:center;gap:.3rem;flex-wrap:wrap}
.counter-badge{display:inline-block;padding:.1rem .35rem;border-radius:4px;background:var(--bg-row);
border:1px solid var(--border);font-size:.66rem;color:var(--text);margin-right:.25rem}
.counter-badge.nonzero{color:var(--yellow);border-color:var(--yellow)}
.module-badge{display:inline-block;padding:.1rem .35rem;border-radius:4px;background:var(--bg-row);
border:1px solid var(--red);color:var(--red);font-size:.66rem;margin:.1rem .2rem 0 0}
.empty{color:var(--p31-dim);font-size:.8rem;font-style:italic}
.hostname-col{color:var(--cyan);white-space:nowrap}
.toast{position:fixed;bottom:1rem;right:1rem;background:var(--cyan);color:#07121a;padding:.6rem 1rem;border-radius:6px;
font-size:.8rem;opacity:0;pointer-events:none;transition:opacity .2s;z-index:50}
.toast.show{opacity:1} .toast.error{background:var(--red);color:#fff}
@media(max-width:768px){.main{margin-left:0}}
</style>
</head>
<body class="hybrid-dark">
<nav class="sidebar" id="sidebar"></nav>
<script src="/shared/sidebar.js"></script>
<main class="main">
<header class="header">
<div>
<h1>🛰 Flotte</h1>
<div class="sub" id="sub"><span class="dot" id="dot"></span>chargement…</div>
</div>
<div class="btns">
<button class="btn" id="btn-refresh">↻ Rafraîchir</button>
</div>
</header>
<div class="card">
<h3>Nœuds mesh</h3>
<div class="note">Un instantané signé par nœud (dernier gagne) — publié toutes les 60s par
<code>secubox-metrics-publish.timer</code>, agrégé et vérifié ici. Une ligne grisée /
« hors-ligne » signifie un instantané périmé (aucune preuve fraîche de vie).</div>
<div style="overflow-x:auto;margin-top:.6rem">
<table class="matrix">
<thead>
<tr>
<th>Nœud</th><th>CPU</th><th>Mém.</th><th>Disque</th><th>Load1</th>
<th>Uptime</th><th>Compteurs</th><th>Modules down</th><th>Vu</th>
</tr>
</thead>
<tbody id="fleet-body"></tbody>
</table>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
const API = '/api/v1/annuaire';
const REFRESH_MS = 10000;
const $ = (id) => document.getElementById(id);
function token(){ return localStorage.getItem('sbx_token') || ''; }
function authHeaders(extra){
return Object.assign({'Authorization': 'Bearer ' + token()}, extra || {});
}
function toast(msg, isErr){
const t = $('toast'); t.textContent = msg;
t.className = 'toast show' + (isErr ? ' error' : '');
setTimeout(() => { t.className = 'toast'; }, 2600);
}
async function J(path, opts){
const o = Object.assign({credentials: 'same-origin'}, opts || {});
o.headers = authHeaders(o.headers);
const r = await fetch(API + path, o);
if (!r.ok) {
let detail = 'HTTP ' + r.status;
try { const b = await r.json(); if (b && b.detail) detail = b.detail; } catch (e) { /* ignore */ }
throw new Error(detail);
}
return r.json();
}
// Toutes les valeurs issues de l'API sont posées via textContent/dataset
// uniquement — JAMAIS en assignant du HTML brut à un noeud (garde XSS plus
// stricte que le panneau /centers). Aucune donnée d'API n'apparaît dans un
// attribut d'événement inline ; les actions passent par un unique
// addEventListener délégué qui lit `dataset`.
function clear(el){ while (el.firstChild) el.removeChild(el.firstChild); }
function agoStr(ts){
if (!ts) return '—';
const then = Date.parse(ts);
if (Number.isNaN(then)) return '—';
const s = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (s < 90) return 'il y a ' + s + 's';
if (s < 5400) return 'il y a ' + Math.round(s / 60) + 'm';
return 'il y a ' + Math.round(s / 3600) + 'h';
}
function fmtUptime(sec){
sec = Number(sec) || 0;
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
if (d > 0) return d + 'j ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
function pctBarClass(pct){
if (pct >= 90) return 'crit';
if (pct >= 70) return 'warn';
return '';
}
function healthDot(healthState, isStale){
const dot = document.createElement('span');
dot.className = 'dot ' + (isStale ? 'stale' : (healthState || 'ok'));
return dot;
}
function pctCell(pct){
const wrap = document.createElement('span');
wrap.className = 'cell-inner';
const barWrap = document.createElement('span');
barWrap.className = 'bar-wrap';
const fill = document.createElement('span');
const v = Math.max(0, Math.min(100, Number(pct) || 0));
fill.className = 'bar-fill ' + pctBarClass(v);
fill.style.width = v + '%';
barWrap.appendChild(fill);
const label = document.createElement('span');
label.textContent = v.toFixed(0) + '%';
wrap.appendChild(barWrap);
wrap.appendChild(label);
return wrap;
}
function countersCell(counters){
const wrap = document.createElement('span');
const c = counters || {};
['bans', 'assist_sessions', 'soc_alerts'].forEach((key) => {
const v = c[key] || 0;
const badge = document.createElement('span');
badge.className = 'counter-badge' + (v > 0 ? ' nonzero' : '');
badge.textContent = key + ':' + v;
wrap.appendChild(badge);
});
return wrap;
}
function modulesDownCell(modulesDown){
const wrap = document.createElement('span');
const list = modulesDown || [];
if (list.length === 0) {
const ok = document.createElement('span');
ok.className = 'empty';
ok.textContent = 'aucun';
wrap.appendChild(ok);
return wrap;
}
list.forEach((name) => {
const badge = document.createElement('span');
badge.className = 'module-badge';
badge.textContent = name;
wrap.appendChild(badge);
});
return wrap;
}
function buildRow(node){
const tr = document.createElement('tr');
tr.className = 'node-row' + (node.stale ? ' is-stale' : '');
tr.dataset.nodeDid = node.node_did || '';
const hostTd = document.createElement('td');
hostTd.className = 'hostname-col';
const inner = document.createElement('span');
inner.className = 'cell-inner';
inner.appendChild(healthDot(node.health, node.stale));
const hostSpan = document.createElement('span');
hostSpan.textContent = node.hostname || '(inconnu)';
inner.appendChild(hostSpan);
hostTd.appendChild(inner);
tr.appendChild(hostTd);
const cpuTd = document.createElement('td'); cpuTd.appendChild(pctCell(node.cpu_pct)); tr.appendChild(cpuTd);
const memTd = document.createElement('td'); memTd.appendChild(pctCell(node.mem_pct)); tr.appendChild(memTd);
const diskTd = document.createElement('td'); diskTd.appendChild(pctCell(node.disk_pct)); tr.appendChild(diskTd);
const loadTd = document.createElement('td');
loadTd.textContent = (Number(node.load1) || 0).toFixed(2);
tr.appendChild(loadTd);
const upTd = document.createElement('td');
upTd.textContent = fmtUptime(node.uptime_s);
tr.appendChild(upTd);
const countersTd = document.createElement('td');
countersTd.appendChild(countersCell(node.counters));
tr.appendChild(countersTd);
const modulesTd = document.createElement('td');
modulesTd.appendChild(modulesDownCell(node.modules_down));
tr.appendChild(modulesTd);
const seenTd = document.createElement('td');
seenTd.textContent = node.stale ? 'hors-ligne' : agoStr(node.ts);
tr.appendChild(seenTd);
return tr;
}
function renderNodes(nodes){
const body = $('fleet-body');
clear(body);
if (!nodes || nodes.length === 0) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 9;
td.className = 'empty-cell';
td.textContent = 'aucun nœud publié';
tr.appendChild(td);
body.appendChild(tr);
return;
}
nodes
.slice()
.sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''))
.forEach((node) => body.appendChild(buildRow(node)));
}
// Délégation d'événements : aucune interaction ne dépend d'un attribut
// on*="..." inline ; data-node-did porte l'identifiant (jamais interprété
// comme code, lu via dataset).
$('fleet-body').addEventListener('click', (e) => {
const row = e.target.closest('[data-node-did]');
if (!row) return;
// Réservé pour une future vue détaillée par nœud (data-node-did disponible).
});
async function loadFleet(){
try {
const d = await J('/fleet');
renderNodes(d.nodes || []);
$('dot').className = 'dot on';
const sub = $('sub');
clear(sub);
const dot = document.createElement('span');
dot.className = 'dot on';
sub.appendChild(dot);
sub.appendChild(document.createTextNode((d.nodes || []).length + ' nœud(s)'));
} catch (e) {
$('dot').className = 'dot off';
const sub = $('sub');
clear(sub);
const dot = document.createElement('span');
dot.className = 'dot off';
sub.appendChild(dot);
sub.appendChild(document.createTextNode('flotte indisponible (' + e.message + ')'));
renderNodes([]);
toast('Flotte indisponible : ' + e.message, true);
}
}
$('btn-refresh').addEventListener('click', loadFleet);
loadFleet();
setInterval(loadFleet, REFRESH_MS);
</script>
</body>
</html>

View File

@ -187,3 +187,19 @@ classifier_override = false
# functional = ["^app_theme$"]
# analytics = ["^my_analytics_"]
# marketing = ["^my_ad_id$"]
# ─────────────────────────────────────────────────────────────────────────────
# Fleet metrics (fleet-metrics) — secubox-annuaire
# ─────────────────────────────────────────────────────────────────────────────
# secubox-metrics-publish.timer runs `sbx-fleetctl publish` every 60s, which
# signs a MetricSnapshot (CPU/mem/disk/load/uptime/modules_down/counters)
# with this box's own node key and persists it locally at
# /var/lib/secubox/annuaire/fleet/self.json. The annuaire API serves it
# verbatim on GET /fleet/self (public, mesh-only :8799 — mirrors /log/export)
# and aggregates self + verified peers on the JWT-gated GET /fleet, backing
# the /fleet panel.
[metrics]
# Set to false to opt this box OUT of publishing fleet metrics entirely —
# sbx-fleetctl publish becomes a documented no-op (rc0, {"skipped": ...}).
# The timer stays enabled either way; this is the operator's kill switch.
fleet_publish = true