Compare commits

...

5 Commits

Author SHA1 Message Date
877fb9e19a fix(portal): bind Active bans to /crowdsec/decisions count (2.2.2)
Some checks are pending
License Headers / check (push) Waiting to run
2026-06-29 17:25:16 +02:00
5e4c0d2dac fix(portal): reskin to standard SecuBox template (shared sidebar + C3BOX palette)
Replace the bespoke top navbar with the shared menu-driven sidebar, swap the
P31 green theme for design-tokens/crt-light (C3BOX), wrap content in
main.main + crt-engine. Dashboard content, IDs and data-fetch JS unchanged.
Bumps secubox-portal 2.2.0 -> 2.2.1.
2026-06-29 16:49:30 +02:00
de15937ccf fix(appstore): navbar entry -> /usr/share/secubox/menu.d + 'root' section (0.2.2) 2026-06-29 16:43:26 +02:00
e51a310010 fix(appstore): NoNewPrivileges=no for sudo bridge + try-restart on upgrade (0.2.1) 2026-06-29 16:36:20 +02:00
6034dfb0c3 feat(appstore): Phase B/C — navbar integration + quick actions + config editor
UI integrates the shared sidebar (nav#sidebar + sidebar.js + crt-engine) and
adds per-service quick actions (start/stop/restart/enable/disable) + a config
editor drawer (view/edit module TOML, shows deps). API: POST
/module/{name}/action/{verb}, GET/PUT /module/{name}/config. Privileged work
via validated root helper secubox-appstorectl (secubox-* units +
/etc/secubox/<name>.toml only, TOML-validated writes) + narrow sudoers.
postinst rebuilds the hub menu cache so the store shows in the navbar.
Bumps 0.2.0.
2026-06-29 16:34:17 +02:00
12 changed files with 390 additions and 238 deletions

View File

@ -17,6 +17,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
CATALOG_FILE = Path(os.environ.get( CATALOG_FILE = Path(os.environ.get(
"APPSTORE_CATALOG", "/usr/share/secubox/appstore/catalog.json")) "APPSTORE_CATALOG", "/usr/share/secubox/appstore/catalog.json"))
@ -165,3 +166,73 @@ async def module(name: str):
else: else:
raise HTTPException(status_code=404, detail=f"unknown module {name!r}") raise HTTPException(status_code=404, detail=f"unknown module {name!r}")
return dict(st[name]) return dict(st[name])
# ── Lifecycle + config (Phase B/C) ──────────────────────────────────────────
# The API runs as the unprivileged `secubox` user; all privileged work goes
# through the validated root helper /usr/sbin/secubox-appstorectl via a narrow
# sudoers rule (start/stop/restart/enable/disable + write-config only).
ACTIONS = {"start", "stop", "restart", "enable", "disable"}
APPSTORECTL = "/usr/sbin/secubox-appstorectl"
class ConfigIn(BaseModel):
content: str
def _resolve(name: str, st: dict) -> str:
if name in st:
return name
alt = f"secubox-{name}"
if alt in st:
return alt
raise HTTPException(status_code=404, detail=f"unknown module {name!r}")
def _appstorectl(args, input_text=None):
try:
r = subprocess.run(["sudo", "-n", APPSTORECTL, *args], input=input_text,
capture_output=True, text=True, timeout=90)
return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip()
except Exception as e: # noqa: BLE001
return 1, "", str(e)
def _config_path(name: str) -> Path:
short = name[len("secubox-"):] if name.startswith("secubox-") else name
return Path(f"/etc/secubox/{short}.toml")
@app.post("/module/{name}/action/{verb}")
async def module_action(name: str, verb: str):
name = _resolve(name, compute_state())
if verb not in ACTIONS:
raise HTTPException(status_code=400, detail=f"unknown action {verb!r}")
rc, out, err = _appstorectl([verb, name])
if rc != 0:
raise HTTPException(status_code=500, detail=f"{verb} failed: {err or out}")
new = compute_state(force=True).get(name, {})
return {"status": "ok", "action": verb, "module": name,
"state": new.get("state"), "running": new.get("running"), "message": out}
@app.get("/module/{name}/config")
async def get_config(name: str):
name = _resolve(name, compute_state())
p = _config_path(name)
try:
return {"module": name, "path": str(p), "exists": True,
"readable": True, "content": p.read_text(encoding="utf-8")}
except FileNotFoundError:
return {"module": name, "path": str(p), "exists": False, "readable": True, "content": ""}
except PermissionError:
return {"module": name, "path": str(p), "exists": True, "readable": False, "content": ""}
@app.put("/module/{name}/config")
async def put_config(name: str, body: ConfigIn):
name = _resolve(name, compute_state())
rc, out, err = _appstorectl(["write-config", name], input_text=body.content)
if rc != 0:
raise HTTPException(status_code=400, detail=f"config write failed: {err or out}")
return {"status": "ok", "module": name, "message": out}

View File

@ -1,3 +1,33 @@
secubox-appstore (0.2.2-1~bookworm1) bookworm; urgency=medium
* fix(menu): install the navbar entry to /usr/share/secubox/menu.d (the
dir the hub reads) instead of /etc/secubox/menu.d, and place it in the
'root' navbar section (order 10) so the App Store is prominent.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 20:00:00 +0200
secubox-appstore (0.2.1-1~bookworm1) bookworm; urgency=medium
* fix: service needs NoNewPrivileges=no so the API can sudo the root
helper (the narrow sudoers rule + helper validation are the boundary);
postinst now try-restarts the service on upgrade so new code loads.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 19:30:00 +0200
secubox-appstore (0.2.0-1~bookworm1) bookworm; urgency=medium
* Phase B/C + navbar integration.
- UI: integrated shared sidebar (nav#sidebar + sidebar.js + crt-engine),
per-service quick actions (start/stop/restart/enable/disable) and a
config editor drawer (view/edit the module TOML, shows dependencies).
- api: POST /module/{name}/action/{verb}; GET/PUT /module/{name}/config.
- sbin/secubox-appstorectl: validated root helper (secubox-* units +
/etc/secubox/<name>.toml only; TOML-validates writes). Narrow sudoers
rule lets the unprivileged API invoke it.
- postinst rebuilds the hub menu cache so the App Store shows in the navbar.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 19:00:00 +0200
secubox-appstore (0.1.1-1~bookworm1) bookworm; urgency=medium secubox-appstore (0.1.1-1~bookworm1) bookworm; urgency=medium
* fix(nginx): serve the catalog API from secubox-routes.d/ (the * fix(nginx): serve the catalog API from secubox-routes.d/ (the

View File

@ -7,7 +7,7 @@ Standards-Version: 4.6.2
Package: secubox-appstore Package: secubox-appstore
Architecture: all Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, sudo
Description: SecuBox App Store — module catalog & lifecycle (Phase A) Description: SecuBox App Store — module catalog & lifecycle (Phase A)
Categorized, tiered, searchable catalog of SecuBox modules with live Categorized, tiered, searchable catalog of SecuBox modules with live
install/run state, served as a hub web UI. Phase A is read-only; install, install/run state, served as a hub web UI. Phase A is read-only; install,

View File

@ -3,8 +3,13 @@ set -e
case "$1" in case "$1" in
configure) configure)
install -d -m 0755 /usr/share/secubox/appstore /var/log/secubox /var/lib/secubox 2>/dev/null || true install -d -m 0755 /usr/share/secubox/appstore /var/log/secubox /var/lib/secubox 2>/dev/null || true
# surface the App Store in the hub navbar (menu cache rebuild)
rm -f /var/cache/secubox/menu.json 2>/dev/null || true
# validate the sudoers drop-in (never leave a broken one)
visudo -cf /etc/sudoers.d/secubox-appstore >/dev/null 2>&1 || rm -f /etc/sudoers.d/secubox-appstore
systemctl daemon-reload 2>/dev/null || true systemctl daemon-reload 2>/dev/null || true
systemctl enable --now secubox-appstore.service 2>/dev/null || true systemctl enable --now secubox-appstore.service 2>/dev/null || true
systemctl try-restart secubox-appstore.service 2>/dev/null || true
if systemctl is-active --quiet nginx 2>/dev/null; then if systemctl is-active --quiet nginx 2>/dev/null; then
nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true
fi fi

View File

@ -17,8 +17,8 @@ override_dh_auto_install:
install -m 644 $(CURDIR)/nginx/appstore.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox.d/ install -m 644 $(CURDIR)/nginx/appstore.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox.d/
# Hub menu entry # Hub menu entry
install -d $(CURDIR)/debian/secubox-appstore/etc/secubox/menu.d install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/menu.d
install -m 644 $(CURDIR)/menu.d/580-appstore.json $(CURDIR)/debian/secubox-appstore/etc/secubox/menu.d/ install -m 644 $(CURDIR)/menu.d/580-appstore.json $(CURDIR)/debian/secubox-appstore/usr/share/secubox/menu.d/
# systemd service # systemd service
install -d $(CURDIR)/debian/secubox-appstore/usr/lib/systemd/system install -d $(CURDIR)/debian/secubox-appstore/usr/lib/systemd/system
@ -28,6 +28,12 @@ override_dh_auto_install:
install -d $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d install -d $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d
install -m 644 $(CURDIR)/nginx/appstore-routes.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d/ install -m 644 $(CURDIR)/nginx/appstore-routes.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d/
# Privileged controller + narrow sudoers rule
install -d $(CURDIR)/debian/secubox-appstore/usr/sbin
install -m 755 $(CURDIR)/sbin/secubox-appstorectl $(CURDIR)/debian/secubox-appstore/usr/sbin/
install -d $(CURDIR)/debian/secubox-appstore/etc/sudoers.d
install -m 440 $(CURDIR)/debian/secubox-appstore.sudoers $(CURDIR)/debian/secubox-appstore/etc/sudoers.d/secubox-appstore
# Generate the catalog index from every sibling module's debian/secubox.yaml # Generate the catalog index from every sibling module's debian/secubox.yaml
install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/appstore install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/appstore
python3 $(CURDIR)/scripts/gen-appstore-catalog.py \ python3 $(CURDIR)/scripts/gen-appstore-catalog.py \

View File

@ -16,7 +16,9 @@ RestartSec=5
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
NoNewPrivileges=yes # sudo->secubox-appstorectl bridge requires new privileges; the narrow
# sudoers rule + helper validation are the security boundary.
NoNewPrivileges=no
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox

View File

@ -0,0 +1,3 @@
# secubox-appstore: allow the unprivileged API user to drive module lifecycle
# and config writes ONLY through the validated root helper.
secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-appstorectl

View File

@ -3,8 +3,8 @@
"name": "App Store", "name": "App Store",
"icon": "🛍️", "icon": "🛍️",
"path": "/appstore/", "path": "/appstore/",
"category": "system", "category": "root",
"order": 580, "order": 10,
"description": "Install, enable & configure SecuBox modules", "description": "Install, enable & configure SecuBox modules",
"requires": ["secubox-appstore"] "requires": ["secubox-appstore"]
} }

View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# SecuBox-Deb :: secubox-appstore :: privileged module controller.
# Invoked by the unprivileged secubox-appstore API via a narrow sudoers
# rule. ONLY operates on secubox-*.service units and /etc/secubox/<name>.toml.
set -euo pipefail
action="${1:-}"
module="${2:-}"
# Strict allow-list: module name must be secubox-<slug>, nothing else.
case "$module" in
secubox-[a-z0-9]*) : ;;
*) echo "appstorectl: invalid module name" >&2; exit 2 ;;
esac
unit="${module}.service"
short="${module#secubox-}"
case "$action" in
start|stop|restart|enable|disable)
exec systemctl "$action" "$unit"
;;
write-config)
dest="/etc/secubox/${short}.toml"
tmp="$(mktemp /etc/secubox/.${short}.toml.XXXXXX)"
trap 'rm -f "$tmp"' EXIT
cat > "$tmp"
# Reject anything that is not valid TOML.
python3 -c 'import tomllib,sys; tomllib.load(open(sys.argv[1],"rb"))' "$tmp" \
|| { echo "appstorectl: invalid TOML, refusing to write" >&2; exit 3; }
# Preserve the conventional ownership/perms for SecuBox config.
chown root:secubox "$tmp"
chmod 0640 "$tmp"
mv -f "$tmp" "$dest"
trap - EXIT
echo "wrote $dest"
;;
*)
echo "appstorectl: unknown action '$action'" >&2; exit 2 ;;
esac

View File

@ -7,130 +7,174 @@
<link rel="stylesheet" href="/shared/design-tokens.css"> <link rel="stylesheet" href="/shared/design-tokens.css">
<link rel="stylesheet" href="/shared/crt-light.css"> <link rel="stylesheet" href="/shared/crt-light.css">
<style> <style>
:root{ .as-bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:14px 0}
--bg:#0a0a0f; --panel:#13131c; --line:#2a2a3a; --gold:#c9a84c; .as-bar input,.as-bar select{background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);
--cyan:#00d4ff; --green:#00ff41; --muted:#6b6b7a; --text:#e8e6d9; --cinnabar:#e63946; color:var(--text,#e8e6d9);padding:8px 10px;border-radius:6px;font-family:inherit;font-size:13px}
} .as-bar input{min-width:220px}
*{box-sizing:border-box} .as-stat{color:var(--text-muted,#6b6b7a);font-size:12px;margin-left:auto}
body{margin:0;background:var(--bg);color:var(--text); .as-stat b{color:var(--cyber-cyan,#00d4ff)}
font-family:'JetBrains Mono',ui-monospace,monospace;} .as-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px}
header{padding:18px 24px;border-bottom:1px solid var(--line); .as-card{background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);border-radius:10px;
display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;} padding:14px;display:flex;flex-direction:column;gap:8px}
header h1{font-family:'Cinzel',serif;color:var(--gold);margin:0;font-size:22px;letter-spacing:1px;} .as-card:hover{border-color:var(--gold-hermetic,#c9a84c)}
header .sub{color:var(--muted);font-size:13px} .as-top{display:flex;align-items:center;gap:10px}
.bar{display:flex;gap:10px;flex-wrap:wrap;padding:14px 24px;border-bottom:1px solid var(--line);align-items:center} .as-ico{font-size:24px}
.bar input,.bar select{background:var(--panel);border:1px solid var(--line);color:var(--text); .as-name{font-weight:700;font-size:14px;word-break:break-word}
padding:8px 10px;border-radius:6px;font-family:inherit;font-size:13px} .as-desc{color:var(--text-muted,#6b6b7a);font-size:12px;min-height:30px;line-height:1.4}
.bar input{min-width:220px} .as-badges{display:flex;gap:6px;flex-wrap:wrap}
.stat{color:var(--muted);font-size:12px;margin-left:auto} .pill{font-size:10px;padding:2px 8px;border-radius:20px;border:1px solid var(--line,#2a2a3a);text-transform:uppercase;letter-spacing:.4px}
.stat b{color:var(--cyan)} .pill.cat{color:var(--cyber-cyan,#00d4ff)} .pill.tier{color:var(--gold-hermetic,#c9a84c)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;padding:24px} .pill.s-running{color:var(--matrix-green,#00ff41);border-color:#1d5a2a}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px; .pill.s-installed{color:var(--cyber-cyan,#00d4ff)}
display:flex;flex-direction:column;gap:8px;transition:border-color .15s} .pill.s-available{color:var(--text-muted,#6b6b7a)}
.card:hover{border-color:var(--gold)} .pill.s-tier-locked{color:var(--cinnabar,#e63946);border-color:#5a1d23}
.card .top{display:flex;align-items:center;gap:10px} .as-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto}
.card .icon{font-size:26px;line-height:1} .as-btn{background:transparent;border:1px solid var(--line,#2a2a3a);color:var(--text,#e8e6d9);
.card .name{font-weight:700;color:var(--text);font-size:14px;word-break:break-word} padding:5px 10px;border-radius:6px;font-family:inherit;font-size:11px;cursor:pointer}
.card .desc{color:var(--muted);font-size:12px;min-height:32px;line-height:1.4} .as-btn:hover{border-color:var(--gold-hermetic,#c9a84c)}
.badges{display:flex;gap:6px;flex-wrap:wrap;align-items:center} .as-btn.danger{color:var(--cinnabar,#e63946)} .as-btn:disabled{opacity:.4;cursor:not-allowed}
.pill{font-size:10px;padding:2px 8px;border-radius:20px;border:1px solid var(--line);text-transform:uppercase;letter-spacing:.5px} .as-over{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;z-index:1000;align-items:center;justify-content:center}
.pill.cat{color:var(--cyan);border-color:#1d4a55} .as-over.on{display:flex}
.pill.tier{color:var(--gold);border-color:#5a4a1d} .as-modal{background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);border-radius:12px;
.pill.state-running{color:var(--green);border-color:#1d5a2a;background:rgba(0,255,65,.08)} width:min(720px,92vw);max-height:88vh;overflow:auto;padding:18px}
.pill.state-installed{color:var(--cyan);border-color:#1d4a55} .as-modal h2{font-family:'Cinzel',serif;color:var(--gold-hermetic,#c9a84c);margin:.2em 0}
.pill.state-available{color:var(--muted)} .as-modal textarea{width:100%;min-height:300px;background:#0a0a0f;color:var(--text,#e8e6d9);
.pill.state-tier-locked{color:var(--cinnabar);border-color:#5a1d23} border:1px solid var(--line,#2a2a3a);border-radius:8px;font-family:'JetBrains Mono',monospace;font-size:12px;padding:10px}
.row{display:flex;justify-content:space-between;align-items:center;margin-top:auto} .as-row{display:flex;gap:8px;justify-content:flex-end;margin-top:10px;align-items:center}
.btn{background:transparent;border:1px solid var(--line);color:var(--muted); .as-msg{font-size:12px;margin-right:auto;color:var(--text-muted,#6b6b7a)}
padding:6px 12px;border-radius:6px;font-family:inherit;font-size:12px;cursor:not-allowed} .as-deps{color:var(--text-muted,#6b6b7a);font-size:12px;margin:6px 0}
.ver{color:var(--muted);font-size:11px}
.empty{color:var(--muted);padding:40px;text-align:center;grid-column:1/-1}
</style> </style>
</head> </head>
<body> <body class="crt-light">
<header> <nav class="sidebar" id="sidebar"></nav>
<h1>⬢ SecuBox App Store</h1> <script src="/shared/sidebar.js"></script>
<span class="sub">module catalog · install / enable / configure — <em>Phase A: read-only</em></span>
</header>
<div class="bar"> <main class="main">
<input id="q" type="search" placeholder="Search modules…" oninput="render()"> <header class="header">
<select id="category" onchange="render()"><option value="">All categories</option></select> <div class="header-title">
<select id="tier" onchange="render()"> <h1>🛍️ App Store</h1>
<option value="">All tiers</option><option>lite</option><option>standard</option><option>pro</option><option>all</option> <span class="badge" id="board-badge">tier —</span>
</select> </div>
<select id="state" onchange="render()"> </header>
<option value="">All states</option><option value="running">running</option>
<option value="installed">installed</option><option value="available">available</option> <div class="as-bar">
<option value="tier-locked">tier-locked</option> <input id="q" type="search" placeholder="Search modules…" oninput="render()">
</select> <select id="category" onchange="render()"><option value="">All categories</option></select>
<span class="stat" id="stat">loading…</span> <select id="tier" onchange="render()"><option value="">All tiers</option>
<option>lite</option><option>standard</option><option>pro</option><option>all</option></select>
<select id="state" onchange="render()"><option value="">All states</option>
<option value="running">running</option><option value="installed">installed</option>
<option value="available">available</option><option value="tier-locked">tier-locked</option></select>
<span class="as-stat" id="stat">loading…</span>
</div>
<div class="as-grid" id="grid"></div>
</main>
<!-- Config / detail drawer -->
<div class="as-over" id="over">
<div class="as-modal">
<h2 id="m-title">module</h2>
<div class="as-deps" id="m-deps"></div>
<div class="as-actions" id="m-actions" style="margin-bottom:10px"></div>
<label style="font-size:12px;color:var(--text-muted,#6b6b7a)">Config (<span id="m-path"></span>)</label>
<textarea id="m-config" spellcheck="false"></textarea>
<div class="as-row">
<span class="as-msg" id="m-msg"></span>
<button class="as-btn" onclick="closeModal()">Close</button>
<button class="as-btn" id="m-save" onclick="saveConfig()">Save config</button>
</div>
</div>
</div> </div>
<div class="grid" id="grid"><div class="empty">Loading catalog…</div></div>
<script> <script>
const API = '/api/v1/appstore'; const API='/api/v1/appstore';
const ICONS = {media:'🎬',email:'✉️',ai:'🧠',iot:'🛰️',communication:'💬',publishing:'📰', const ICONS={media:'🎬',email:'✉️',ai:'🧠',iot:'🛰️',communication:'💬',publishing:'📰',
network:'🌐',security:'🛡️',system:'⚙️',vpn:'🔒',dashboard:'📊',monitoring:'📈',misc:'🧩'}; network:'🌐',security:'🛡️',system:'⚙️',vpn:'🔒',dashboard:'📊',monitoring:'📈',misc:'🧩'};
let ALL = []; let ALL=[], CUR=null;
async function getJSON(p){ try{const r=await fetch(API+p);if(!r.ok)throw 0;return await r.json();}catch(e){return null;} } async function jget(p){try{const r=await fetch(API+p);if(!r.ok)throw 0;return await r.json();}catch(e){return null;}}
async function jsend(p,m,b){try{const r=await fetch(API+p,{method:m,headers:{'Content-Type':'application/json'},
body:b?JSON.stringify(b):undefined});const j=await r.json().catch(()=>({}));return{ok:r.ok,j};}catch(e){return{ok:false,j:{detail:String(e)}};}}
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':s;return d.innerHTML;} function esc(s){const d=document.createElement('div');d.textContent=s==null?'':s;return d.innerHTML;}
async function load(){ async function load(){
const cats = await getJSON('/categories'); const cats=await jget('/categories');
if(cats && cats.categories){ if(cats&&cats.categories){const sel=document.getElementById('category');
const sel=document.getElementById('category'); cats.categories.forEach(c=>{const o=document.createElement('option');o.value=c.name;o.textContent=`${c.name} (${c.count})`;sel.appendChild(o);});}
cats.categories.forEach(c=>{const o=document.createElement('option');o.value=c.name; const data=await jget('/catalog');
o.textContent=`${c.name} (${c.count})`;sel.appendChild(o);}); ALL=(data&&Array.isArray(data.modules))?data.modules:[];
} document.getElementById('board-badge').textContent='tier '+((cats&&cats.board_tier)||'?');
const data = await getJSON('/catalog'); render();
ALL = (data && Array.isArray(data.modules)) ? data.modules : [];
render(cats && cats.board_tier);
} }
function render(boardTier){ function actionsFor(m){
if(!m.installed) return `<button class="as-btn" disabled title="Install arrives in a later phase">Install</button>`;
const b=[];
if(m.running){ b.push(`<button class="as-btn" onclick="act('${m.name}','restart')">Restart</button>`);
b.push(`<button class="as-btn danger" onclick="act('${m.name}','stop')">Stop</button>`); }
else { b.push(`<button class="as-btn" onclick="act('${m.name}','start')">Start</button>`); }
b.push(`<button class="as-btn" onclick="act('${m.name}','enable')">Enable</button>`);
b.push(`<button class="as-btn danger" onclick="act('${m.name}','disable')">Disable</button>`);
b.push(`<button class="as-btn" onclick="openModal('${m.name}')">Config</button>`);
return b.join('');
}
function render(){
const q=document.getElementById('q').value.toLowerCase(); const q=document.getElementById('q').value.toLowerCase();
const cat=document.getElementById('category').value; const cat=document.getElementById('category').value, tier=document.getElementById('tier').value, state=document.getElementById('state').value;
const tier=document.getElementById('tier').value; let items=ALL.filter(m=>{
const state=document.getElementById('state').value; if(cat&&m.category!==cat)return false; if(tier&&m.tier!==tier)return false; if(state&&m.state!==state)return false;
let items = ALL.filter(m=>{ if(q&&!(m.name.toLowerCase().includes(q)||(m.description||'').toLowerCase().includes(q)))return false; return true;});
if(cat && m.category!==cat) return false; const running=ALL.filter(m=>m.state==='running').length, installed=ALL.filter(m=>m.installed).length;
if(tier && m.tier!==tier) return false; document.getElementById('stat').innerHTML=`showing <b>${items.length}</b>/${ALL.length} · installed <b>${installed}</b> · running <b>${running}</b>`;
if(state && m.state!==state) return false;
if(q && !(m.name.toLowerCase().includes(q) || (m.description||'').toLowerCase().includes(q))) return false;
return true;
});
const grid=document.getElementById('grid'); const grid=document.getElementById('grid');
const running = ALL.filter(m=>m.state==='running').length; if(!items.length){grid.innerHTML='<div style="color:var(--text-muted,#6b6b7a);padding:30px">No modules match.</div>';return;}
const installed = ALL.filter(m=>m.installed).length; grid.innerHTML=items.map(m=>{
document.getElementById('stat').innerHTML = const ico=ICONS[m.category]||ICONS.misc, label=m.name.replace(/^secubox-/,''), st=m.state||'available';
`showing <b>${items.length}</b> / ${ALL.length} · installed <b>${installed}</b> · running <b>${running}</b>`; return `<div class="as-card">
if(!items.length){ grid.innerHTML='<div class="empty">No modules match.</div>'; return; } <div class="as-top"><span class="as-ico">${ico}</span><span class="as-name">${esc(label)}</span></div>
grid.innerHTML = items.map(m=>{ <div class="as-desc">${esc(m.description||'')}</div>
const icon = ICONS[m.category]||ICONS.misc; <div class="as-badges"><span class="pill cat">${esc(m.category)}</span>
const label = m.name.replace(/^secubox-/,''); <span class="pill tier">${esc(m.tier)}</span><span class="pill s-${st}">${st}</span>
const st = m.state||'available'; ${m.version?`<span class="pill">v${esc(m.version)}</span>`:''}</div>
const btn = st==='running'?'Running' : st==='installed'?'Installed' : <div class="as-actions">${actionsFor(m)}</div>
st==='tier-locked'?'Tier locked' : 'Install'; </div>`;}).join('');
return `<div class="card">
<div class="top"><span class="icon">${icon}</span><span class="name">${esc(label)}</span></div>
<div class="desc">${esc(m.description||'')}</div>
<div class="badges">
<span class="pill cat">${esc(m.category)}</span>
<span class="pill tier">${esc(m.tier)}</span>
<span class="pill state-${st}">${st}</span>
</div>
<div class="row">
<span class="ver">${m.version?('v'+esc(m.version)):''}</span>
<button class="btn" title="Lifecycle actions arrive in Phase C" disabled>${btn}</button>
</div>
</div>`;
}).join('');
} }
document.addEventListener('DOMContentLoaded', load); async function act(name,verb){
const r=await jsend(`/module/${name}/action/${verb}`,'POST');
if(!r.ok){alert(`${verb} failed: ${(r.j&&r.j.detail)||'error'}`);return;}
const data=await jget('/catalog'); ALL=(data&&Array.isArray(data.modules))?data.modules:ALL; render();
if(CUR&&CUR===name) renderModalActions();
}
async function openModal(name){
CUR=name; document.getElementById('m-title').textContent=name.replace(/^secubox-/,'');
const m=ALL.find(x=>x.name===name)||{};
document.getElementById('m-deps').innerHTML='Depends: '+((m.depends&&m.depends.length)?m.depends.map(esc).join(', '):'—');
renderModalActions();
document.getElementById('m-msg').textContent='loading config…';
document.getElementById('m-config').value='';
document.getElementById('over').classList.add('on');
const cfg=await jget(`/module/${name}/config`);
const ta=document.getElementById('m-config'), save=document.getElementById('m-save');
document.getElementById('m-path').textContent=(cfg&&cfg.path)||'';
if(cfg&&cfg.readable===false){ta.value='';ta.disabled=true;save.disabled=true;document.getElementById('m-msg').textContent='config not readable by appstore';}
else if(cfg&&!cfg.exists){ta.value='';ta.disabled=false;save.disabled=false;document.getElementById('m-msg').textContent='no config file yet — saving creates it';}
else{ta.value=(cfg&&cfg.content)||'';ta.disabled=false;save.disabled=false;document.getElementById('m-msg').textContent='';}
}
function renderModalActions(){const m=ALL.find(x=>x.name===CUR)||{};document.getElementById('m-actions').innerHTML=actionsFor(m);}
function closeModal(){document.getElementById('over').classList.remove('on');CUR=null;}
async function saveConfig(){
if(!CUR)return; const content=document.getElementById('m-config').value;
document.getElementById('m-msg').textContent='saving…';
const r=await jsend(`/module/${CUR}/config`,'PUT',{content});
document.getElementById('m-msg').textContent=r.ok?'saved ✓ (restart the service to apply)':('save failed: '+((r.j&&r.j.detail)||'error'));
}
document.getElementById('over').addEventListener('click',e=>{if(e.target.id==='over')closeModal();});
document.addEventListener('DOMContentLoaded',load);
</script> </script>
<script src="/shared/crt-engine.js"></script>
</body> </body>
</html> </html>

View File

@ -1,3 +1,21 @@
secubox-portal (2.2.2-1~bookworm1) bookworm; urgency=medium
* fix(ui): 'Active bans' now reflects the live /crowdsec/decisions list
(includes CAPI community blocklist) instead of the local-only
health/summary.active_decisions; hero 'Bans active' kept consistent.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 21:00:00 +0200
secubox-portal (2.2.1-1~bookworm1) bookworm; urgency=medium
* fix(ui): reskin the portal to the standard SecuBox template — shared
sidebar (nav#sidebar + sidebar.js) replacing the bespoke top navbar,
design-tokens + crt-light C3BOX palette replacing the P31 green theme,
main.main layout + crt-engine. Dashboard content, element IDs and the
data-fetch JS are unchanged.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 20:30:00 +0200
secubox-portal (2.2.0-1~bookworm1) bookworm; urgency=medium secubox-portal (2.2.0-1~bookworm1) bookworm; urgency=medium
* portal: regenerate /portal/index.html as a public-facing operational * portal: regenerate /portal/index.html as a public-facing operational

View File

@ -5,74 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox · Portal</title> <title>SecuBox · Portal</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/shared/design-tokens.css">
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/crt-light.css"> <link rel="stylesheet" href="/shared/crt-light.css">
<style> <style>
:root {
--p31-peak: #00dd44;
--p31-hot: #00ff55;
--p31-mid: #009933;
--p31-dim: #006622;
--p31-ghost: #003311;
--p31-decay: #ffb347;
--p31-decay-dim: #cc7722;
--tube-light: #e8f5e9;
--tube-pale: #c8e6c9;
--tube-soft: #a5d6a7;
--tube-dark: #1b1b1f;
--primary: var(--p31-peak);
--cyan: #00d4ff;
--red: #ff4466;
--yellow: var(--p31-decay);
--bloom-text: 0 0 2px var(--p31-peak), 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
--bloom-amber: 0 0 3px var(--p31-decay), 0 0 10px rgba(255,179,71,0.4);
--bloom-red: 0 0 3px var(--red), 0 0 10px rgba(255,68,102,0.4);
}
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { a { color: var(--cyber-cyan, #00d4ff); text-decoration: none; }
font-family: 'Courier Prime', 'Courier New', monospace; a:hover { color: var(--gold-hermetic, #c9a84c); }
background: var(--tube-light);
background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%);
color: var(--tube-dark);
min-height: 100vh;
padding-bottom: 3rem;
}
body::before {
content: "";
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,221,68,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,221,68,0.02) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none; z-index: -1;
}
a { color: var(--p31-mid); text-decoration: none; }
a:hover { color: var(--p31-peak); text-shadow: var(--bloom-text); }
/* Navbar */
.navbar {
background: var(--tube-dark);
color: var(--p31-peak);
padding: 0.75rem 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 50;
border-bottom: 1px solid var(--p31-mid);
}
.navbar .brand {
font-weight: bold; font-size: 1.1rem; letter-spacing: 1px;
text-shadow: var(--bloom-text);
}
.navbar .brand a { color: var(--p31-peak); }
.navbar .nav-links { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.navbar .nav-links a {
color: var(--p31-mid); font-size: 0.9rem;
padding: 0.25rem 0.5rem; border-radius: 3px;
transition: all 0.15s;
}
.navbar .nav-links a:hover { color: var(--p31-peak); background: rgba(0,221,68,0.08); text-shadow: var(--bloom-text); }
.navbar .nav-meta { font-size: 0.75rem; color: var(--p31-dim); }
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; } .container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
@ -80,10 +18,9 @@
.hero { .hero {
margin-bottom: 2rem; margin-bottom: 2rem;
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
background: var(--tube-pale); background: var(--panel, #13131c);
border: 1px solid var(--p31-mid); border: 1px solid var(--line, #2a2a3a);
border-radius: 4px; border-radius: 4px;
box-shadow: 0 0 12px rgba(0,221,68,0.15);
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
align-items: center; align-items: center;
@ -95,16 +32,16 @@
} }
.hero-score .num { .hero-score .num {
font-size: 3.5rem; font-weight: bold; line-height: 1; font-size: 3.5rem; font-weight: bold; line-height: 1;
color: var(--p31-peak); text-shadow: var(--bloom-text); color: var(--matrix-green, #00ff41);
} }
.hero-score .label { font-size: 0.8rem; color: var(--p31-dim); margin-top: 0.25rem; letter-spacing: 1px; } .hero-score .label { font-size: 0.8rem; color: var(--text-muted, #6b6b7a); margin-top: 0.25rem; letter-spacing: 1px; }
.hero-score.warn .num { color: var(--yellow); text-shadow: var(--bloom-amber); } .hero-score.warn .num { color: var(--gold-hermetic, #c9a84c); }
.hero-score.crit .num { color: var(--red); text-shadow: var(--bloom-red); } .hero-score.crit .num { color: var(--cinnabar, #e63946); }
.hero-info { font-size: 0.95rem; line-height: 1.6; } .hero-info { font-size: 0.95rem; line-height: 1.6; }
.hero-info .kv { display: flex; gap: 1.5rem; flex-wrap: wrap; } .hero-info .kv { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.hero-info .k { color: var(--p31-dim); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; } .hero-info .k { color: var(--text-muted, #6b6b7a); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; }
.hero-info .v { color: var(--tube-dark); font-weight: bold; } .hero-info .v { color: var(--text, #e8e6d9); font-weight: bold; }
.hero-refresh { font-size: 0.75rem; color: var(--p31-dim); text-align: right; } .hero-refresh { font-size: 0.75rem; color: var(--text-muted, #6b6b7a); text-align: right; }
/* Section grid */ /* Section grid */
.grid { .grid {
@ -114,101 +51,87 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.card { .card {
background: var(--tube-light); background: var(--panel, #13131c);
border: 1px solid var(--p31-mid); border: 1px solid var(--line, #2a2a3a);
border-radius: 4px; border-radius: 4px;
padding: 1.25rem; padding: 1.25rem;
position: relative; position: relative;
box-shadow: 0 0 8px rgba(0,221,68,0.08);
} }
.card h3 { .card h3 {
font-size: 0.85rem; text-transform: uppercase; letter-spacing: 2px; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 2px;
color: var(--p31-mid); margin-bottom: 1rem; color: var(--gold-hermetic, #c9a84c); margin-bottom: 1rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px dashed var(--p31-ghost); border-bottom: 1px dashed var(--line, #2a2a3a);
text-shadow: var(--bloom-text);
} }
.card .row { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 0.9rem; } .card .row { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 0.9rem; }
.card .row .k { color: var(--p31-dim); } .card .row .k { color: var(--text-muted, #6b6b7a); }
.card .row .v { color: var(--tube-dark); font-weight: bold; } .card .row .v { color: var(--text, #e8e6d9); font-weight: bold; }
.card .row .v.ok { color: var(--p31-peak); text-shadow: var(--bloom-text); } .card .row .v.ok { color: var(--matrix-green, #00ff41); }
.card .row .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); } .card .row .v.warn { color: var(--gold-hermetic, #c9a84c); }
.card .row .v.crit { color: var(--red); text-shadow: var(--bloom-red); } .card .row .v.crit { color: var(--cinnabar, #e63946); }
.empty { color: var(--p31-dim); font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; } .empty { color: var(--text-muted, #6b6b7a); font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
/* Bar */ /* Bar */
.bar { .bar {
height: 4px; background: var(--p31-ghost); border-radius: 2px; height: 4px; background: var(--cosmos-black, #0a0a0f); border-radius: 2px;
overflow: hidden; margin: 0.25rem 0; overflow: hidden; margin: 0.25rem 0;
} }
.bar > span { display: block; height: 100%; background: var(--p31-peak); } .bar > span { display: block; height: 100%; background: var(--matrix-green, #00ff41); }
.bar.warn > span { background: var(--yellow); } .bar.warn > span { background: var(--gold-hermetic, #c9a84c); }
.bar.crit > span { background: var(--red); } .bar.crit > span { background: var(--cinnabar, #e63946); }
/* Modules LED grid */ /* Modules LED grid */
.modules { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; } .modules { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; }
.module { .module {
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
gap: 0.25rem; padding: 0.5rem; border-radius: 4px; gap: 0.25rem; padding: 0.5rem; border-radius: 4px;
background: var(--tube-pale); border: 1px solid var(--p31-ghost); background: var(--cosmos-black, #0a0a0f); border: 1px solid var(--line, #2a2a3a);
font-size: 0.7rem; font-size: 0.7rem;
} }
.module .led { .module .led {
width: 10px; height: 10px; border-radius: 50%; width: 10px; height: 10px; border-radius: 50%;
background: var(--p31-dim); background: var(--text-muted, #6b6b7a);
} }
.module.ok .led { background: var(--p31-peak); box-shadow: 0 0 8px var(--p31-peak); } .module.ok .led { background: var(--matrix-green, #00ff41); box-shadow: 0 0 6px var(--matrix-green, #00ff41); }
.module.warn .led { background: var(--yellow); box-shadow: 0 0 8px var(--yellow); } .module.warn .led { background: var(--gold-hermetic, #c9a84c); box-shadow: 0 0 6px var(--gold-hermetic, #c9a84c); }
.module.err .led { background: var(--red); box-shadow: 0 0 8px var(--red); } .module.err .led { background: var(--cinnabar, #e63946); box-shadow: 0 0 6px var(--cinnabar, #e63946); }
.module .name { font-size: 0.65rem; text-transform: uppercase; color: var(--p31-dim); } .module .name { font-size: 0.65rem; text-transform: uppercase; color: var(--text-muted, #6b6b7a); }
/* Tables — vhosts / ASNs */ /* Tables — vhosts / ASNs */
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.25rem 0.5rem; } th, td { text-align: left; padding: 0.25rem 0.5rem; }
th { font-size: 0.7rem; color: var(--p31-dim); text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px dashed var(--p31-ghost); } th { font-size: 0.7rem; color: var(--text-muted, #6b6b7a); text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px dashed var(--line, #2a2a3a); }
td.num { text-align: right; color: var(--cyan); font-variant-numeric: tabular-nums; } td.num { text-align: right; color: var(--cyber-cyan, #00d4ff); font-variant-numeric: tabular-nums; }
tr:hover td { background: rgba(0,221,68,0.04); } tr:hover td { background: rgba(201,168,76,0.05); }
/* Big number tiles */ /* Big number tiles */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; } .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; }
.tile { .tile {
background: var(--tube-pale); border: 1px solid var(--p31-ghost); background: var(--cosmos-black, #0a0a0f); border: 1px solid var(--line, #2a2a3a);
border-radius: 4px; padding: 0.75rem; text-align: center; border-radius: 4px; padding: 0.75rem; text-align: center;
} }
.tile .v { font-size: 1.75rem; font-weight: bold; color: var(--p31-peak); text-shadow: var(--bloom-text); } .tile .v { font-size: 1.75rem; font-weight: bold; color: var(--matrix-green, #00ff41); }
.tile .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); } .tile .v.warn { color: var(--gold-hermetic, #c9a84c); }
.tile .v.crit { color: var(--red); text-shadow: var(--bloom-red); } .tile .v.crit { color: var(--cinnabar, #e63946); }
.tile .l { font-size: 0.7rem; text-transform: uppercase; color: var(--p31-dim); letter-spacing: 1px; } .tile .l { font-size: 0.7rem; text-transform: uppercase; color: var(--text-muted, #6b6b7a); letter-spacing: 1px; }
.footer { .footer {
margin-top: 3rem; padding-top: 1.5rem; margin-top: 3rem; padding-top: 1.5rem;
border-top: 1px dashed var(--p31-ghost); border-top: 1px dashed var(--line, #2a2a3a);
text-align: center; font-size: 0.8rem; color: var(--p31-dim); text-align: center; font-size: 0.8rem; color: var(--text-muted, #6b6b7a);
} }
.footer a { color: var(--p31-mid); } .footer a { color: var(--cyber-cyan, #00d4ff); }
@media (max-width: 768px) { @media (max-width: 768px) {
.hero { grid-template-columns: 1fr; } .hero { grid-template-columns: 1fr; }
.modules { grid-template-columns: repeat(3, 1fr); } .modules { grid-template-columns: repeat(3, 1fr); }
.navbar { padding: 0.75rem 1rem; flex-direction: column; gap: 0.5rem; }
.navbar .nav-links { font-size: 0.8rem; }
} }
</style> </style>
</head> </head>
<body> <body class="crt-light">
<nav class="sidebar" id="sidebar"></nav>
<nav class="navbar"> <script src="/shared/sidebar.js"></script>
<div class="brand"><a href="/portal/">🛡️ SecuBox</a></div>
<div class="nav-links">
<a href="/portal/">PORTAL</a>
<a href="/soc/">SOC</a>
<a href="/metablogizer/">METABLOGS</a>
<a href="/publish/">PUBLISH</a>
<a href="/cookies/">COOKIES</a>
<a href="https://github.com/CyberMind-FR/secubox-deb" rel="noopener">REPO</a>
</div>
<div class="nav-meta" id="nav-hostname"></div>
</nav>
<main class="main">
<div class="container"> <div class="container">
<!-- Hero --> <!-- Hero -->
@ -295,7 +218,9 @@
</footer> </footer>
</div> </div>
</main>
<script src="/shared/crt-engine.js"></script>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
@ -430,7 +355,12 @@
} }
function renderBans(h, decisions) { function renderBans(h, decisions) {
const ban = h && h.crowdsec && h.crowdsec.active_decisions; // Prefer the live /crowdsec/decisions list (includes CAPI community
// blocklist), falling back to health/summary's local-only count.
const decList = decisions && Array.isArray(decisions.decisions) ? decisions.decisions
: (Array.isArray(decisions) ? decisions : null);
const ban = decList ? decList.filter(d => (d.type || 'ban') === 'ban').length
: (h && h.crowdsec ? h.crowdsec.active_decisions : null);
const alerts = h && h.crowdsec && h.crowdsec.alerts_today; const alerts = h && h.crowdsec && h.crowdsec.alerts_today;
const wafPct = h && h.waf && h.waf.blocked_pct; const wafPct = h && h.waf && h.waf.blocked_pct;
const wafCls = wafPct == null ? '' : (wafPct >= 25 ? 'crit' : wafPct >= 10 ? 'warn' : 'ok'); const wafCls = wafPct == null ? '' : (wafPct >= 25 ? 'crit' : wafPct >= 10 ? 'warn' : 'ok');
@ -440,6 +370,8 @@
$('t-waf').textContent = wafPct != null ? wafPct + '%' : '—'; $('t-waf').textContent = wafPct != null ? wafPct + '%' : '—';
$('t-waf').className = 'v ' + wafCls; $('t-waf').className = 'v ' + wafCls;
$('t-alerts').textContent = alerts != null ? alerts : '—'; $('t-alerts').textContent = alerts != null ? alerts : '—';
// Keep the hero "Bans active" consistent with the real blocked-IP count.
if (ban != null && $('hero-bans')) $('hero-bans').textContent = ban;
} }
function renderVhosts(d) { function renderVhosts(d) {