mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
Some checks are pending
License Headers / check (push) Waiting to run
'Pubs bloquées: 0' was misleading — ad-blocking moved to the DNS layer (secubox-adblock-sync #740: Unbound sinkholes 757,946 ad/tracker domains via always_nxdomain), so ads die BEFORE reaching the engine → the engine's 204 counter reads ~0. /admin/ad-stats now includes dns_sinkhole (from ad-guard sinkhole-status.json) and the #ads tab leads with a '🛡️ Blocage DNS' card (domains + rules + sources), relabels the engine card '204 moteur MITM'. Live gk2.
760 lines
43 KiB
HTML
760 lines
43 KiB
HTML
<!DOCTYPE html>
|
|
<!--
|
|
SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
|
Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
|
SecuBox-Deb :: ToolBoX admin dashboard (P31 light skin) — sub-tab nav (#513)
|
|
-->
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SecuBox — ToolBoX (cabine numérique)</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<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/sidebar-light.css">
|
|
<style>
|
|
:root{--p31-peak:#00dd44;--p31-hot:#00ff55;--p31-mid:#009933;--p31-dim:#006622;--p31-decay:#ffb347;--tube-light:#e8f5e9;--tube-pale:#c8e6c9;--tube-soft:#a5d6a7;--bg-card:var(--tube-pale);--border:var(--tube-soft);--text-dim:var(--p31-dim);--red:#ff4466;--purple:#9e76ff;--bloom-text:0 0 2px var(--p31-peak),0 0 6px var(--p31-peak);--bloom-soft:0 0 6px var(--p31-peak)}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Courier Prime',monospace;background:var(--tube-light);color:var(--p31-mid);display:flex;min-height:100vh}
|
|
.sidebar{width:220px;position:fixed;height:100vh;overflow-y:auto}
|
|
.main{flex:1;margin-left:220px;padding:1.5rem}
|
|
.header{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.5rem;border:1px solid var(--border);background:var(--bg-card);margin-bottom:1rem}
|
|
.header h1{font-size:1.4rem;color:var(--p31-hot);text-shadow:var(--bloom-text)}
|
|
.badge{font-size:0.85rem;color:var(--p31-dim);padding:0.2rem 0.6rem;border:1px solid var(--border);border-radius:3px}
|
|
/* Tor switch status badges (#683) */
|
|
.badge-green{color:var(--p31-hot);border-color:var(--p31-peak);text-shadow:var(--bloom-soft)}
|
|
.badge-yellow{color:var(--p31-decay);border-color:var(--p31-decay)}
|
|
.badge-red{color:var(--red);border-color:var(--red)}
|
|
.btn-green{border-color:var(--p31-peak)!important;color:var(--p31-hot)!important}
|
|
/* Sub-tab nav (#513) */
|
|
.tabs{display:flex;gap:0.3rem;margin-bottom:1.2rem;border-bottom:1px solid var(--border);flex-wrap:wrap}
|
|
.tab{font-family:inherit;font-size:0.9rem;padding:0.5rem 1rem;background:transparent;color:var(--p31-dim);border:1px solid var(--border);border-bottom:none;cursor:pointer;border-radius:3px 3px 0 0}
|
|
.tab:hover{color:var(--p31-peak);background:rgba(0,221,68,0.05)}
|
|
.tab.active{color:var(--p31-hot);text-shadow:var(--bloom-text);background:var(--bg-card);border-color:var(--p31-mid);font-weight:bold}
|
|
.panel{display:none}
|
|
.panel.active{display:block;animation:fade .15s ease-in}
|
|
@keyframes fade{from{opacity:0}to{opacity:1}}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:1rem}
|
|
.card{background:var(--bg-card);border:1px solid var(--border);padding:1rem;box-shadow:var(--bloom-soft)}
|
|
.card h2{font-size:1rem;color:var(--p31-hot);text-shadow:var(--bloom-text);margin-bottom:0.6rem;padding-bottom:0.4rem;border-bottom:1px solid var(--border)}
|
|
.kv{display:grid;grid-template-columns:1fr 2fr;gap:0.3rem 0.8rem;font-size:0.88rem}
|
|
.kv .k{color:var(--p31-dim)}
|
|
.kv .v{color:var(--p31-peak);text-shadow:var(--bloom-text)}
|
|
table{width:100%;border-collapse:collapse;font-size:0.85rem}
|
|
th,td{text-align:left;padding:0.4rem 0.5rem;border-bottom:1px solid var(--border)}
|
|
th{color:var(--p31-hot);text-shadow:var(--bloom-text)}
|
|
.empty{color:var(--text-dim);font-style:italic;padding:1rem;text-align:center}
|
|
.toolbar{display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap}
|
|
button{font-family:inherit;font-size:0.9rem;padding:0.4rem 0.9rem;background:transparent;color:var(--p31-peak);border:1px solid var(--p31-mid);cursor:pointer;text-shadow:var(--bloom-text)}
|
|
button:hover{background:rgba(0,221,68,0.1)}
|
|
.r2-banner{background:rgba(255,179,71,0.08);border-left:2px solid var(--p31-decay);padding:0.6rem 0.8rem;margin-bottom:1rem;font-size:0.85rem;color:#996b1a}
|
|
.state-validated{color:var(--p31-peak);text-shadow:var(--bloom-text)}
|
|
.state-throttle{color:var(--p31-decay)}
|
|
.state-quarantine{color:var(--red)}
|
|
/* level chips + switcher (folded from kbin /admin/) */
|
|
.chip{display:inline-block;padding:0.1rem 0.45rem;border-radius:99px;font-size:0.7rem;font-weight:bold}
|
|
.chip.r0{background:#cfd8cf;color:#555}
|
|
.chip.r1{background:rgba(0,221,68,0.25);color:var(--p31-mid)}
|
|
.chip.r2{background:rgba(255,179,71,0.3);color:#996b1a}
|
|
.chip.r3{background:rgba(158,118,255,0.25);color:#6a4fd0}
|
|
.lvlbtn{font-size:0.68rem;padding:0.12rem 0.4rem;margin-right:0.15rem;border:1px solid var(--p31-dim)}
|
|
code{background:rgba(0,0,0,0.05);padding:0.1rem 0.3rem;border-radius:2px;font-size:0.78rem}
|
|
a.link{color:var(--p31-mid);text-decoration:underline}
|
|
.filterlist{max-height:60vh;overflow-y:auto;margin:0;padding:0}
|
|
.filterlist li{font-size:0.82rem;padding:0.2rem 0;border-bottom:1px solid var(--border);list-style:none;display:flex;justify-content:space-between;align-items:center}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="sidebar" id="sidebar"></div>
|
|
<div class="main">
|
|
<div class="header">
|
|
<h1>📡 ToolBoX — Cabine numérique</h1>
|
|
<span class="badge" id="version-badge">v—</span>
|
|
</div>
|
|
|
|
<div class="r2-banner" id="r2-banner">
|
|
<strong>R2 actif :</strong> TLS-break opt-in. Voir #475 / #474 pour la doctrine.
|
|
</div>
|
|
|
|
<!-- Sub-tab navigation (#513) -->
|
|
<nav class="tabs" id="tabs">
|
|
<button class="tab active" data-tab="overview" onclick="switchTab('overview')">📊 Vue d'ensemble</button>
|
|
<button class="tab" data-tab="clients" onclick="switchTab('clients')">👥 Clients</button>
|
|
<button class="tab" data-tab="filtres" onclick="switchTab('filtres')">🚦 Filtres MITM</button>
|
|
<button class="tab" data-tab="social" onclick="switchTab('social')">🕸️ Cartographie sociale</button>
|
|
<button class="tab" data-tab="ads" onclick="switchTab('ads')">🛑 Pubs</button>
|
|
<button class="tab" data-tab="reseau" onclick="switchTab('reseau')">📡 Réseau</button>
|
|
<button class="tab" data-tab="tor" onclick="switchTab('tor')">🧅 Tor</button>
|
|
<button class="tab" data-tab="config" onclick="switchTab('config')">⚙ Config</button>
|
|
</nav>
|
|
|
|
<!-- Overview -->
|
|
<section class="panel active" id="panel-overview">
|
|
<div class="toolbar">
|
|
<button onclick="refreshAll()">🔁 Refresh</button>
|
|
</div>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h2>📊 Live metrics (24h)</h2>
|
|
<div class="kv" id="metrics"><span class="k">loading…</span><span class="v"></span></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Clients (folds in the kbin /admin/ level switcher) -->
|
|
<section class="panel" id="panel-clients">
|
|
<div class="toolbar">
|
|
<button onclick="loadClients()">🔁 Refresh</button>
|
|
<button type="button" onclick="resetAllClients()" style="color:var(--red,#e63946)">↺ Reset all</button>
|
|
</div>
|
|
<div class="card" style="margin-bottom:1rem">
|
|
<h2>👥 Clients actifs</h2>
|
|
<div id="clients"><div class="empty">loading…</div></div>
|
|
</div>
|
|
<div class="card" style="display:none" id="client-detail-card">
|
|
<h2 id="detail-title">🔍 Détails client</h2>
|
|
<div id="client-detail"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Filtres MITM (bypass list) -->
|
|
<section class="panel" id="panel-filtres">
|
|
<div class="toolbar">
|
|
<button onclick="loadFilters()">🔁 Refresh</button>
|
|
</div>
|
|
<div class="card">
|
|
<h2>🚦 Hosts bypassés (mitm ignore_hosts)</h2>
|
|
<p style="font-size:0.82rem;color:var(--p31-dim);margin-bottom:0.6rem">
|
|
Hosts exclus de l'inspection TLS (cert-pinning détecté ou whitelist statique).
|
|
</p>
|
|
<p style="font-size:0.72rem;color:#888">Sources : 🌱 seed (paquet) · ✋ static (opérateur) · 🔍 learned (cert-pin auto)</p>
|
|
<p style="font-size:0.72rem;color:var(--p31-gold,#c9a84c)">🧅 Ces hosts (+ la liste TLS-splice) sont aussi <b>refusés à l'exposition Tor</b> par le module exposure — on ne ré-expose jamais anonymement un endpoint que les clients épinglent.</p>
|
|
<ul class="filterlist" id="filters"><li class="empty">loading…</li></ul>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Cartographie sociale (Phase 11 operator aggregate) -->
|
|
<section class="panel" id="panel-social">
|
|
<div class="toolbar">
|
|
<button onclick="loadSocial()">🔁 Refresh</button>
|
|
</div>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h2>🕸️ Agrégat trackers (24h)</h2>
|
|
<div class="kv" id="social-kpi"><span class="k">loading…</span><span class="v"></span></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>🛰️ Réseaux CDN / edge</h2>
|
|
<div id="social-cdn"><div class="empty">loading…</div></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>🤖 Anti-bot / "prouvez que vous êtes humain"</h2>
|
|
<div id="social-antibot"><div class="empty">loading…</div></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>📡 Opérateur-grade / proche-État</h2>
|
|
<div id="social-opgrade"><div class="empty">loading…</div></div>
|
|
</div>
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h2>🛡️ Blocages par appareil (13.C)</h2>
|
|
<div id="social-devblocks"><div class="empty">loading…</div></div>
|
|
</div>
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h2>🎯 Top tracker domains</h2>
|
|
<div id="social-trackers"><div class="empty">loading…</div></div>
|
|
</div>
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h2>👤 Clients anonymisés</h2>
|
|
<div id="social-clients"><div class="empty">loading…</div></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Pubs bloquées (#656 — contextual ad-block metrics) -->
|
|
<section class="panel" id="panel-ads">
|
|
<div class="toolbar">
|
|
<button onclick="loadAds()">🔁 Refresh</button>
|
|
</div>
|
|
<div id="ads-section" class="grid">
|
|
<div class="card" style="grid-column:1/-1;border-left:3px solid var(--matrix-green,#00ff41)">
|
|
<h2>🛡️ Blocage DNS (couche principale)</h2>
|
|
<div class="kv" id="ads-dns"><span class="k">loading…</span><span class="v"></span></div>
|
|
<p style="font-size:.72rem;color:var(--p31-dim,#888);margin-top:.4rem">La plupart des pubs sont tuées ICI, au DNS, avant d'atteindre le moteur MITM — le compteur « 204 » ci-dessous ne mesure QUE ce qui passe le DNS.</p>
|
|
</div>
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h2>🛑 Bloqués par le moteur MITM (204, 24h)</h2>
|
|
<div class="kv" id="ads-kpi"><span class="k">loading…</span><span class="v"></span></div>
|
|
</div>
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h2>🎯 Top hôtes publicitaires bloqués</h2>
|
|
<div id="ads-hosts"><div class="empty">loading…</div></div>
|
|
</div>
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h2>🔎 Trackers/pubs détectés <span style="font-size:.72rem;opacity:.6">(candidats — observés, pas encore promus au blocage)</span></h2>
|
|
<div id="ads-candidates"><div class="empty">loading…</div></div>
|
|
</div>
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h2>🌐 Top sites visités</h2>
|
|
<div id="ads-sites"><div class="empty">loading…</div></div>
|
|
</div>
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h2>👤 Top visiteurs (trackers/pubs bloqués)</h2>
|
|
<div id="ads-visitors"><div class="empty">loading…</div></div>
|
|
<div id="ads-client-detail"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- #758 — network-layer stats (nft named counters + interface throughput) -->
|
|
<section class="panel" id="panel-reseau">
|
|
<h2>📡 Statistiques réseau (nftables)</h2>
|
|
<div id="ns-stale" style="display:none;color:var(--cinnabar,#e63946);font-size:.85em">⚠ données périmées (collecteur arrêté ?)</div>
|
|
<div id="ns-kpi" class="kpis"></div>
|
|
<h3>Débit interfaces (24h)</h3>
|
|
<div id="ns-throughput"></div>
|
|
<h3>Drops & attaques par catégorie (24h)</h3>
|
|
<div id="ns-drops"></div>
|
|
</section>
|
|
|
|
<!-- Tor egress quick-switch (#683) -->
|
|
<section class="panel" id="panel-tor">
|
|
<div class="toolbar">
|
|
<button onclick="loadTor()">🔁 Refresh</button>
|
|
<button type="button" onclick="torNewnym()">🔄 Nouvelle identité</button>
|
|
<button type="button" onclick="torLeaks()">🔍 Vérifier l'IP de sortie</button>
|
|
</div>
|
|
<div class="card" style="margin-bottom:1rem">
|
|
<h2>🧅 Mode Tor <span class="badge" id="tor-badge">…</span></h2>
|
|
<p style="font-size:0.82rem;color:var(--p31-dim,#888);margin-bottom:0.6rem">
|
|
Quick-switch : route l'egress des workers MITM par le réseau Tor. L'inspection
|
|
(ad-block, poison, bannière, safe-browsing) reste intacte — seule l'IP de sortie
|
|
change. Fail-closed (pas de repli clearnet), sans fuite DNS. Armement asynchrone
|
|
(quelques secondes via le reconciler).
|
|
</p>
|
|
<div class="kv" id="tor-stats"><span class="k">loading…</span><span class="v"></span></div>
|
|
<div style="margin-top:0.8rem;display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">
|
|
<button type="button" class="btn-green" id="tor-on-btn" onclick="torSet(true)">🛡️ Activer le mode Tor</button>
|
|
<button type="button" id="tor-off-btn" onclick="torSet(false)">⏹ Désactiver</button>
|
|
<label style="font-size:0.8rem;color:var(--p31-dim,#888)">Preset
|
|
<select id="tor-preset">
|
|
<option value="anonymous">anonymous</option>
|
|
<option value="stealth">stealth (bridges)</option>
|
|
<option value="minimal">minimal (SOCKS)</option>
|
|
</select>
|
|
</label>
|
|
<span id="tor-msg" style="font-size:0.8rem;color:var(--p31-peak,#0a0)"></span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Config -->
|
|
<section class="panel" id="panel-config">
|
|
<div class="toolbar">
|
|
<button onclick="window.open('/api/v1/toolbox/admin/config', '_blank')">⚙ Config TOML brute</button>
|
|
</div>
|
|
<div class="card">
|
|
<h2>⚙ Configuration</h2>
|
|
<div class="kv" id="cfg-summary"><span class="k">loading…</span><span class="v"></span></div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script src="/shared/sidebar.js"></script>
|
|
<script>
|
|
const API = '/api/v1/toolbox';
|
|
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
|
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + name));
|
|
// Lazy-load the heavier tabs only when first opened.
|
|
if (name === 'filtres') loadFilters();
|
|
if (name === 'social') loadSocial();
|
|
if (name === 'ads') loadAds();
|
|
if (name === 'reseau') loadNetstats();
|
|
if (name === 'tor') loadTor();
|
|
location.hash = name;
|
|
}
|
|
|
|
async function J(path) {
|
|
try {
|
|
const r = await fetch(API + path, { credentials: 'same-origin' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return await r.json();
|
|
} catch (e) { return { __error: e.message }; }
|
|
}
|
|
|
|
async function loadCfg() {
|
|
const c = await J('/admin/config');
|
|
const el = document.getElementById('cfg-summary');
|
|
if (c.__error) { el.innerHTML = `<span class="k">err</span><span class="v">${c.__error}</span>`; return; }
|
|
el.innerHTML = `
|
|
<span class="k">SSID</span> <span class="v">${c.ap.ssid}</span>
|
|
<span class="k">iface</span> <span class="v">${c.ap.iface}</span>
|
|
<span class="k">channel</span> <span class="v">${c.ap.channel}</span>
|
|
<span class="k">DHCP</span> <span class="v">${c.dhcp.range_start} → ${c.dhcp.range_end}</span>
|
|
<span class="k">portal</span> <span class="v">${c.portal.listen_host}:${c.portal.listen_port}</span>
|
|
<span class="k">R2 enabled</span> <span class="v">${c.r2.enabled ? '✓' : '✗'}</span>
|
|
<span class="k">MITM listen</span> <span class="v">${c.r2.mitm_listen}</span>
|
|
<span class="k">quarantine ≥</span> <span class="v">${c.quarantine.score_quarantine}</span>
|
|
`;
|
|
document.getElementById('r2-banner').style.display = c.r2.enabled ? '' : 'none';
|
|
}
|
|
|
|
function levelChip(lvl) {
|
|
const l = (lvl || 'r1').toLowerCase();
|
|
return `<span class="chip ${l}">${l.toUpperCase()}</span>`;
|
|
}
|
|
|
|
function levelSwitcher(macHash, current) {
|
|
const cur = (current || 'r1').toLowerCase();
|
|
return ['r0','r1','r2','r3'].map(l =>
|
|
l === cur ? '' :
|
|
`<button class="lvlbtn" onclick="setLevel('${macHash}','${l}')">${l.toUpperCase()}</button>`
|
|
).join('');
|
|
}
|
|
|
|
async function setLevel(macHash, level) {
|
|
try {
|
|
const fd = new FormData(); fd.append('level', level);
|
|
const r = await fetch(`${API}/admin/clients/${macHash}/level`, {
|
|
method: 'POST', body: fd, credentials: 'same-origin'
|
|
});
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
loadClients();
|
|
} catch (e) { alert('Échec changement niveau : ' + e.message); }
|
|
}
|
|
|
|
async function loadClients() {
|
|
const d = await J('/admin/clients/rich');
|
|
const el = document.getElementById('clients');
|
|
const rows = (d && d.clients) ? d.clients : (Array.isArray(d) ? d : null);
|
|
if (!rows) { el.innerHTML = `<div class="empty">${(d && d.__error) || 'no data'}</div>`; return; }
|
|
if (!rows.length) { el.innerHTML = '<div class="empty">no clients</div>'; return; }
|
|
const shown = rows.slice(0, 5); // #575 — cap the list to top 5
|
|
const escA = s => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
let html = '<table><thead><tr><th>MAC (hash)</th><th>IP / type</th><th>state</th><th>niveau</th><th>score</th><th>last</th><th>Actions</th></tr></thead><tbody>';
|
|
for (const c of shown) {
|
|
const ago = c.last_seen ? Math.round((Date.now()/1000 - c.last_seen) / 60) + 'm' : '—';
|
|
html += `<tr>
|
|
<td><code>${c.mac_hash}</code></td>
|
|
<td>${c.ip || '—'} <span style="white-space:nowrap">${c.device_emoji||''} ${c.flag||''}</span>${c.asn_org ? `<br><span style="color:var(--p31-dim,#888);font-size:0.72rem" title="${escA(c.device||'')}">${escA(c.asn_org)}</span>` : ''}</td>
|
|
<td><span class="state-${c.state}">${c.state || '—'}</span></td>
|
|
<td>${levelChip(c.level)} ${levelSwitcher(c.mac_hash, c.level)}</td>
|
|
<td>${c.score ?? '—'}</td>
|
|
<td>${ago}</td>
|
|
<td>
|
|
<a class="link" href="${API}/admin/clients/${c.mac_hash}/social" target="_blank" style="font-size:0.8rem">🕸️ Carto</a>
|
|
·
|
|
<a class="link" href="${API}/admin/clients/${c.mac_hash}/report" target="_blank" style="font-size:0.8rem">PDF</a>
|
|
·
|
|
<a class="link" href="javascript:void(0)" onclick="loadClientDetail('${c.mac_hash}')" style="font-size:0.8rem">Events</a>
|
|
·
|
|
<a class="link" href="javascript:void(0)" onclick="resetClient('${c.mac_hash}')" style="font-size:0.8rem;color:var(--red)">↺ Reset</a>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table>';
|
|
if (rows.length > shown.length) {
|
|
html += `<div class="empty">+ ${rows.length - shown.length} autres clients (top 5 affichés)</div>`;
|
|
}
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
// Phase 12.B — RAZ a specific client's accumulated statistics
|
|
// (social mapping graph + toolbox events). Operator-gated.
|
|
async function resetClient(macHash) {
|
|
if (!confirm(`Remettre à zéro les statistiques du client ${macHash.slice(0,16)}… ?\n\nCela efface sa cartographie sociale + ses events. Irréversible.`)) return;
|
|
try {
|
|
const r = await fetch(`${API}/admin/clients/${macHash}/reset`, {
|
|
method: 'POST', credentials: 'same-origin'
|
|
});
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const j = await r.json();
|
|
alert(`RAZ effectuée : ${j.rows_deleted || 0} enregistrements supprimés.`);
|
|
loadClients();
|
|
} catch (e) { alert('Échec RAZ : ' + e.message); }
|
|
}
|
|
|
|
async function resetAllClients() {
|
|
if (!confirm('Remettre à zéro TOUS les clients ? (events, consentements, graphes social — scores remis à zéro, clients conservés)')) return;
|
|
try {
|
|
const r = await fetch(`${API}/admin/clients/reset-all`, {method:'POST', credentials:'same-origin'});
|
|
const d = await r.json().catch(()=>({}));
|
|
if (!r.ok) { alert('Reset all refusé: ' + (d.detail || ('HTTP ' + r.status))); return; }
|
|
await loadClients();
|
|
if (typeof loadSocial === 'function') loadSocial();
|
|
} catch (e) {
|
|
alert('Reset all erreur: ' + e);
|
|
}
|
|
}
|
|
|
|
async function quarantine(ip) {
|
|
if (!ip) return;
|
|
if (!confirm(`Mettre en quarantaine l'appareil ${ip} ? Tout son trafic sera coupé 6h.`)) return;
|
|
try {
|
|
const r = await fetch(`${API}/admin/quarantine/${encodeURIComponent(ip)}`, { method: 'POST', credentials: 'same-origin' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
alert('Appareil en quarantaine (6h).');
|
|
loadSocial();
|
|
} catch (e) { alert('Échec quarantaine : ' + e.message); }
|
|
}
|
|
|
|
async function loadMetrics() {
|
|
const m = await J('/admin/metrics');
|
|
const el = document.getElementById('metrics');
|
|
if (m.__error) { el.innerHTML = `<span class="k">err</span><span class="v">${m.__error}</span>`; return; }
|
|
const ev = m.events_by_source || {};
|
|
let evHtml = '';
|
|
for (const src of ['dpi','cookies','soc','ja4','avatar']) {
|
|
const n = ev[src] || 0;
|
|
evHtml += `<span class="k">${src}</span><span class="v">${n}</span>`;
|
|
}
|
|
el.innerHTML = `
|
|
<span class="k">Clients actifs</span> <span class="v">${m.clients_active || 0}</span>
|
|
<span class="k">Events 24h</span> <span class="v">${m.events_24h_total || 0}</span>
|
|
${evHtml}
|
|
<span class="k">MITM connections</span> <span class="v">${m.mitm?.connections || 0}</span>
|
|
<span class="k">MITM hosts</span> <span class="v">${m.mitm?.unique_hosts || 0}</span>
|
|
<span class="k">Cert-pin blocks</span> <span class="v">${m.mitm?.tls_pinned || 0}</span>
|
|
`;
|
|
}
|
|
|
|
async function loadClientDetail(macHash) {
|
|
const card = document.getElementById('client-detail-card');
|
|
const title = document.getElementById('detail-title');
|
|
const div = document.getElementById('client-detail');
|
|
title.textContent = `🔍 Détails client — ${macHash}`;
|
|
card.style.display = '';
|
|
card.scrollIntoView({behavior:'smooth', block:'nearest'});
|
|
div.innerHTML = '<div class="empty">loading…</div>';
|
|
const d = await J(`/admin/clients/${macHash}/events`);
|
|
if (d.__error) { div.innerHTML = `<div class="empty">${d.__error}</div>`; return; }
|
|
const ev = d.events_by_source || {};
|
|
const summary = Object.keys(ev).length
|
|
? Object.entries(ev).map(([s,n]) => `<span style="margin-right:1rem"><b>${s}</b>: ${n}</span>`).join('')
|
|
: '<i style="color:var(--text-dim)">aucun event</i>';
|
|
let recentHtml = '';
|
|
if (d.recent && d.recent.length) {
|
|
recentHtml = '<table><thead><tr><th>Time</th><th>Source</th><th>Détail</th></tr></thead><tbody>';
|
|
for (const e of d.recent) {
|
|
const t = new Date(e.ts*1000).toLocaleTimeString();
|
|
const detail = e.host || e.url || e.sni || e.kind || '?';
|
|
recentHtml += `<tr><td>${t}</td><td><b>${e.source}</b></td><td><code>${detail}</code></td></tr>`;
|
|
}
|
|
recentHtml += '</tbody></table>';
|
|
}
|
|
div.innerHTML = `
|
|
<p style="margin-bottom:0.8rem">${summary}</p>
|
|
<p style="margin-bottom:0.5rem;font-size:0.85rem;color:var(--p31-dim)">Récents :</p>
|
|
${recentHtml}
|
|
<p style="margin-top:0.8rem">
|
|
<a class="link" href="${API}/admin/clients/${macHash}/report" target="_blank">⬇ Télécharger PDF complet</a>
|
|
</p>
|
|
`;
|
|
}
|
|
|
|
async function loadHealth() {
|
|
// Liveness card removed (redundant generic status); /health still drives the
|
|
// top version-badge.
|
|
const h = await J('/health');
|
|
if (h && h.version) document.getElementById('version-badge').textContent = `v${h.version}`;
|
|
}
|
|
|
|
async function loadFilters() {
|
|
const el = document.getElementById('filters');
|
|
const d = await J('/admin/filter-control/list');
|
|
const items = (d && d.hosts) ? d.hosts : (Array.isArray(d) ? d : null);
|
|
if (!items) { el.innerHTML = `<li class="empty">${(d && d.__error) || 'no data'}</li>`; return; }
|
|
if (!items.length) { el.innerHTML = '<li class="empty">aucun host bypassé</li>'; return; }
|
|
const BADGE = {seed: '🌱 seed', static: '✋ static', learned: '🔍 learned',
|
|
'splice-seed': '🔀 splice', 'splice-learned': '🔀 splice·auto'};
|
|
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
// Show ALL entries (bypass + splice) — every explicit splicing must be
|
|
// displayed + known. The <ul> scrolls (see .filterlist max-height).
|
|
el.innerHTML = `<li style="opacity:.6;font-size:.72rem">${items.length} hôtes exclus (MITM-bypass + splice)</li>` + items.map(h => {
|
|
const host = typeof h === 'string' ? h : (h.host || h.pattern || JSON.stringify(h));
|
|
const src = (typeof h === 'object' && h.source) ? h.source : '';
|
|
const badge = BADGE[src] || esc(src);
|
|
const tor = ` <span title="refusé à l'exposition Tor" style="color:var(--p31-gold,#c9a84c);font-size:0.72rem">🧅 Tor-exclu</span>`;
|
|
return `<li><code>${esc(host)}</code>${badge ? ` <span style="color:var(--p31-dim,#888);font-size:0.72rem">${badge}</span>` : ''}${tor}</li>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ── kbin Tor egress quick-switch (#683) ──
|
|
async function loadTor() {
|
|
const st = await J('/admin/tor/state');
|
|
const badge = document.getElementById('tor-badge');
|
|
const stats = document.getElementById('tor-stats');
|
|
if (st.__error) {
|
|
badge.textContent = 'err'; badge.className = 'badge badge-red';
|
|
stats.innerHTML = `<span class="k">err</span><span class="v">${st.__error}</span>`;
|
|
return;
|
|
}
|
|
const on = !!st.tor_mode;
|
|
badge.textContent = on ? (st.running ? `ON · ${st.bootstrap||0}%` : 'ON (tor down)') : 'OFF';
|
|
badge.className = 'badge ' + (on ? (st.running && st.bootstrap >= 100 ? 'badge-green' : 'badge-yellow') : '');
|
|
const psel = document.getElementById('tor-preset');
|
|
if (psel && st.tor_preset) psel.value = st.tor_preset;
|
|
stats.innerHTML = `
|
|
<span class="k">Mode Tor</span> <span class="v">${on ? '🧅 activé' : '⚪ désactivé'}</span>
|
|
<span class="k">Daemon</span> <span class="v">${st.running ? '🟢 running' : '⚪ stopped'}</span>
|
|
<span class="k">Bootstrap</span> <span class="v">${st.bootstrap || 0}%</span>
|
|
<span class="k">Circuits</span> <span class="v">${st.circuits || 0}</span>
|
|
<span class="k">IP de sortie</span><span class="v">${st.exit_ip || '— (cliquez « Vérifier »)'}</span>
|
|
<span class="k">Preset</span> <span class="v">${st.tor_preset || 'anonymous'}</span>
|
|
`;
|
|
}
|
|
|
|
async function torSet(on) {
|
|
const msg = document.getElementById('tor-msg');
|
|
const preset = (document.getElementById('tor-preset') || {}).value || 'anonymous';
|
|
msg.textContent = on ? 'armement…' : 'désactivation…';
|
|
try {
|
|
const body = on ? JSON.stringify({tor_preset: preset}) : '{}';
|
|
const r = await fetch(`${API}/admin/tor/${on ? 'on' : 'off'}`, {
|
|
method: 'POST', headers: {'content-type': 'application/json'},
|
|
body, credentials: 'same-origin'
|
|
});
|
|
if (!r.ok) throw new Error('HTTP ' + r.status + (r.status === 403 ? ' (admin.gk2 only)' : ''));
|
|
msg.textContent = on ? 'armé ✓ (tunnel dans quelques secondes)' : 'désarmé ✓';
|
|
setTimeout(() => { msg.textContent = ''; loadTor(); }, 1500);
|
|
} catch (e) { msg.textContent = 'échec : ' + e.message; }
|
|
}
|
|
|
|
async function torNewnym() {
|
|
const msg = document.getElementById('tor-msg');
|
|
msg.textContent = 'nouveau circuit…';
|
|
try {
|
|
const r = await fetch(`${API}/admin/tor/newnym`, {method: 'POST', credentials: 'same-origin'});
|
|
const d = await r.json();
|
|
msg.textContent = (r.ok && d.success) ? 'nouvelle identité ✓' : 'échec NEWNYM';
|
|
setTimeout(() => { msg.textContent = ''; loadTor(); }, 1500);
|
|
} catch (e) { msg.textContent = 'échec : ' + e.message; }
|
|
}
|
|
|
|
async function torLeaks() {
|
|
const msg = document.getElementById('tor-msg');
|
|
msg.textContent = 'sonde SOCKS…';
|
|
try {
|
|
const r = await fetch(`${API}/admin/tor/check-leaks`, {method: 'POST', credentials: 'same-origin'});
|
|
const d = await r.json();
|
|
msg.textContent = d.ok ? `IP de sortie : ${d.tor_ip}` : ('échec : ' + (d.error || 'tor down'));
|
|
setTimeout(() => loadTor(), 800);
|
|
} catch (e) { msg.textContent = 'échec : ' + e.message; }
|
|
}
|
|
|
|
async function loadSocial() {
|
|
const agg = await J('/admin/social-aggregate?hours=24');
|
|
const kpi = document.getElementById('social-kpi');
|
|
const trk = document.getElementById('social-trackers');
|
|
const cli = document.getElementById('social-clients');
|
|
const cdn = document.getElementById('social-cdn');
|
|
if (agg.__error) {
|
|
kpi.innerHTML = `<span class="k">err</span><span class="v">${agg.__error}</span>`;
|
|
trk.innerHTML = cli.innerHTML = '';
|
|
if (cdn) cdn.innerHTML = '';
|
|
return;
|
|
}
|
|
kpi.innerHTML = `
|
|
<span class="k">Clients actifs</span> <span class="v">${agg.active_clients || 0}</span>
|
|
<span class="k">Trackers vus</span> <span class="v">${agg.total_trackers_seen || 0}</span>
|
|
<span class="k">Hors-UE (Phase C)</span> <span class="v">${agg.extra_eu_trackers || 0}</span>
|
|
<span class="k">Fenêtre</span> <span class="v">${agg.window_hours || 24}h</span>
|
|
`;
|
|
const td = agg.by_tracker_domain || [];
|
|
const escT = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
if (!td.length) {
|
|
trk.innerHTML = '<div class="empty">aucun tracker dans la fenêtre</div>';
|
|
} else {
|
|
const row = r => `<tr><td><code>${escT(r.tracker_domain)}</code></td><td>${r.hits}</td><td>${r.clients}</td></tr>`;
|
|
const top = td.slice(0, 5).map(row).join('');
|
|
const rest = td.slice(5).map(row).join('');
|
|
let html = '<table><thead><tr><th>Tracker domain</th><th>hits</th><th>clients</th></tr></thead>'
|
|
+ '<tbody>' + top + '</tbody>';
|
|
if (rest) {
|
|
html += '<tbody id="sbx-trk-rest" style="display:none">' + rest + '</tbody>';
|
|
}
|
|
html += '</table>';
|
|
if (rest) {
|
|
html += `<button type="button" onclick="(function(b){var x=document.getElementById('sbx-trk-rest');var on=x.style.display==='none';x.style.display=on?'':'none';b.textContent=on?'▴ réduire':'▾ tout afficher (${td.length})';})(this)" style="margin-top:6px;background:none;border:0;color:var(--p31,#2C70C0);cursor:pointer;font-size:0.8rem">▾ tout afficher (${td.length})</button>`;
|
|
}
|
|
trk.innerHTML = html;
|
|
}
|
|
const bc = agg.by_client || [];
|
|
cli.innerHTML = bc.length
|
|
? '<table><thead><tr><th>Client (hash)</th><th>sites</th><th>trackers</th><th>last</th></tr></thead><tbody>' +
|
|
bc.slice(0, 5).map(r => {
|
|
const ago = r.last_seen ? Math.round((Date.now()/1000 - r.last_seen)/60) + 'm' : '—';
|
|
return `<tr><td><code>${(r.client_mac_hash||'').slice(0,16)}</code></td><td>${r.sites}</td><td>${r.trackers}</td><td>${ago}</td></tr>`;
|
|
}).join('') +
|
|
'</tbody></table>'
|
|
: '<div class="empty">aucun client</div>';
|
|
if (cdn) {
|
|
const bv = agg.by_cdn || [];
|
|
cdn.innerHTML = bv.length
|
|
? '<table><thead><tr><th>CDN / edge</th><th>hôtes</th></tr></thead><tbody>' +
|
|
bv.slice(0, 5).map(r => `<tr><td>${r.cdn_vendor}</td><td>${r.hosts}</td></tr>`).join('') +
|
|
'</tbody></table>'
|
|
: '<div class="empty">aucun CDN détecté</div>';
|
|
}
|
|
const ab = document.getElementById('social-antibot');
|
|
if (ab) {
|
|
const ba = agg.by_antibot || [];
|
|
ab.innerHTML = ba.length
|
|
? `<p style="font-size:0.8rem;color:var(--p31-dim);margin-bottom:0.5rem">${agg.antibot_clients||0} client(s) défié(s)</p>` +
|
|
'<table><thead><tr><th>Vendor</th><th>sites</th><th>clients</th><th>défis</th></tr></thead><tbody>' +
|
|
ba.slice(0, 5).map(r => `<tr><td>🤖 ${r.antibot_vendor}</td><td>${r.sites}</td><td>${r.clients}</td><td>${r.challenges}</td></tr>`).join('') +
|
|
'</tbody></table>'
|
|
: '<div class="empty">aucun anti-bot détecté</div>';
|
|
}
|
|
const dbk = document.getElementById('social-devblocks');
|
|
if (dbk) {
|
|
const db = await J('/admin/device-blocks?hours=24');
|
|
const bd = (db && db.by_device) || [];
|
|
dbk.innerHTML = bd.length
|
|
? `<p style="font-size:0.8rem;color:var(--p31-dim);margin-bottom:0.5rem">${db.devices||0} appareil(s) · ${db.total_hits||0} blocages 24h</p>` +
|
|
'<table><thead><tr><th>Appareil (hash)</th><th>IP</th><th>blacklist</th><th>DoH</th><th>total</th><th>Quarantaine</th></tr></thead><tbody>' +
|
|
bd.slice(0, 5).map(r => `<tr><td><code>${(r.mac_hash||'').slice(0,16)}</code></td><td>${r.ip||'—'}</td><td>${r.blacklist||0}</td><td>${r.doh||0}</td><td>${r.total||0}</td><td><a class="link" href="javascript:void(0)" onclick="quarantine('${r.ip||''}')" style="color:var(--red);font-size:0.78rem">⛔ Quarantaine</a></td></tr>`).join('') +
|
|
'</tbody></table>'
|
|
: '<div class="empty">aucun blocage attribué (24h)</div>';
|
|
}
|
|
const og = document.getElementById('social-opgrade');
|
|
if (og) {
|
|
const bo = agg.by_opgrade || [];
|
|
og.innerHTML = bo.length
|
|
? `<p style="font-size:0.8rem;color:var(--p31-dim);margin-bottom:0.5rem">${agg.opgrade_clients||0} client(s) exposé(s)</p>` +
|
|
'<table><thead><tr><th>Vendor</th><th>catégorie</th><th>sites</th><th>clients</th><th>events</th></tr></thead><tbody>' +
|
|
bo.slice(0, 5).map(r => `<tr><td>📡 ${r.opgrade_vendor}</td><td>${r.category}</td><td>${r.sites}</td><td>${r.clients}</td><td>${r.events}</td></tr>`).join('') +
|
|
'</tbody></table>'
|
|
: '<div class="empty">aucune surface opérateur-grade détectée</div>';
|
|
}
|
|
}
|
|
|
|
async function loadAds() {
|
|
const d = await J('/admin/ad-stats?hours=24');
|
|
const kpi = document.getElementById('ads-kpi');
|
|
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
if (!d || d.__error) { kpi.innerHTML = `<span class="k">err</span><span class="v">${(d&&d.__error)||'no data'}</span>`; return; }
|
|
// #ads — DNS-layer blocking (the real ad-blocker; engine 204s are secondary)
|
|
const dns = d.dns_sinkhole || {};
|
|
const dnsEl = document.getElementById('ads-dns');
|
|
if (dnsEl) {
|
|
dnsEl.innerHTML = dns.enabled
|
|
? `<span class="k">État</span> <span class="v" style="color:var(--matrix-green,#00ff41)">🟢 actif</span>`
|
|
+ ` <span class="k">Domaines pub/tracker sinkholés</span> <span class="v">${(dns.domains||0).toLocaleString('fr-FR')}</span>`
|
|
+ ` <span class="k">Règles réseau</span> <span class="v">${(dns.net_rules||0).toLocaleString('fr-FR')}</span>`
|
|
+ ` <span class="k">Sources</span> <span class="v">${(dns.sources||[]).join(', ')||'—'}</span>`
|
|
: (dns.enabled === false
|
|
? `<span class="k">État</span> <span class="v" style="color:var(--cinnabar,#e63946)">⚪ désactivé</span>`
|
|
: `<span class="k">État</span> <span class="v">indisponible</span>`);
|
|
}
|
|
kpi.innerHTML = `<span class="k">Pubs bloquées (204)</span> <span class="v">${d.total_blocked||0}</span>`
|
|
+ ` <span class="k">Trackers détectés</span> <span class="v">${d.trackers_seen||0}</span>`
|
|
+ ` <span class="k">Pages nettoyées</span> <span class="v">${d.pages_cleaned||0}</span>`
|
|
+ ` <span class="k">Drops réseau</span> <span class="v">${d.network_drops||0}</span>`
|
|
+ ` <span class="k" title="estimation : un contenu bloqué n'est jamais téléchargé, on ne peut pas mesurer les octets réels — ~45 Ko/blocage">Ko évités <span style="opacity:.6">(est.)</span></span> <span class="v">~${Math.round((d.total_bytes||0)/1024)}</span>`;
|
|
const hostRows = (d.top_hosts||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td><td>~${Math.round((r.bytes||0)/1024)}</td></tr>`).join('');
|
|
const siteRows = (d.top_sites||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.site)}</code></td><td>${r.hits}</td></tr>`).join('');
|
|
document.getElementById('ads-hosts').innerHTML = hostRows
|
|
? '<table><thead><tr><th>Tracker / pub host</th><th>bloqués</th><th>Ko (est.)</th></tr></thead><tbody>'+hostRows+'</tbody></table>'
|
|
: '<div class="empty">rien de bloqué dans la fenêtre</div>';
|
|
document.getElementById('ads-sites').innerHTML = siteRows
|
|
? '<table><thead><tr><th>Site</th><th>trackers/pubs bloqués</th></tr></thead><tbody>'+siteRows+'</tbody></table>'
|
|
: '';
|
|
// #802 — detected candidates (observed, awaiting promotion to active blocking)
|
|
const candRows = (d.top_candidates||[]).slice(0, 15).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td></tr>`).join('');
|
|
document.getElementById('ads-candidates').innerHTML = candRows
|
|
? `<div style="font-size:.78rem;opacity:.7;margin-bottom:.4rem">${d.total_candidates||0} hôtes détectés dans la fenêtre</div><table><thead><tr><th>Hôte détecté</th><th>observations</th></tr></thead><tbody>${candRows}</tbody></table>`
|
|
: '<div class="empty">aucun tracker/pub détecté dans la fenêtre</div>';
|
|
const visRows = (d.top_visitors||[]).slice(0, 5).map(r=>{
|
|
const mh = esc(r.mac_hash);
|
|
return `<tr><td><a href="#" onclick="loadAdsClient('${mh}');return false;"><code>${mh}</code></a></td><td>${r.hits}</td></tr>`;
|
|
}).join('');
|
|
document.getElementById('ads-visitors').innerHTML = visRows
|
|
? '<table><thead><tr><th>Visiteur</th><th>trackers/pubs</th></tr></thead><tbody>'+visRows+'</tbody></table>'
|
|
: '<div class="empty">aucun visiteur dans la fenêtre</div>';
|
|
}
|
|
|
|
async function loadAdsClient(mh) {
|
|
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
const detail = document.getElementById('ads-client-detail');
|
|
detail.innerHTML = '<div class="empty">loading…</div>';
|
|
const d = await J('/admin/ad-stats/client/'+encodeURIComponent(mh));
|
|
if (!d || d.__error) { detail.innerHTML = `<div class="empty">${(d&&d.__error)||'no data'}</div>`; return; }
|
|
const rows = (d.top_hosts||[]).slice(0, 5).map(r=>`<tr><td><code>${esc(r.host)}</code></td><td>${r.hits}</td><td>${Math.round((r.bytes||0)/1024)}</td></tr>`).join('');
|
|
detail.innerHTML = `<h3>👤 <code>${esc(d.mac_hash)}</code> — ${d.total||0} trackers/pubs bloqués</h3>`
|
|
+ (rows
|
|
? '<table><thead><tr><th>Ad host</th><th>bloqués</th><th>Ko</th></tr></thead><tbody>'+rows+'</tbody></table>'
|
|
: '<div class="empty">aucune pub bloquée pour ce visiteur</div>');
|
|
}
|
|
|
|
// ── Réseau tab (#758) — hub netstats endpoints ──
|
|
async function Jhub(path) {
|
|
try {
|
|
const r = await fetch('/api/v1/hub' + path, { credentials: 'same-origin' });
|
|
if (!r.ok) return { __error: 'HTTP ' + r.status };
|
|
return await r.json();
|
|
} catch (e) { return { __error: String(e) }; }
|
|
}
|
|
|
|
function sparkline(points, color) {
|
|
// points: [[ts, value], …] → inline SVG polyline, auto-scaled.
|
|
if (!points || !points.length) return '<span style="opacity:.5">—</span>';
|
|
const W = 240, H = 40, vals = points.map(p => p[1]);
|
|
const max = Math.max(1, ...vals), n = points.length;
|
|
const path = points.map((p, i) =>
|
|
`${(i / Math.max(1, n - 1) * W).toFixed(1)},${(H - p[1] / max * H).toFixed(1)}`).join(' ');
|
|
return `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
|
|
+ `<polyline fill="none" stroke="${color}" stroke-width="1.5" points="${path}"/></svg>`;
|
|
}
|
|
|
|
function fmtBps(b) {
|
|
if (b > 1e6) return (b / 1e6).toFixed(1) + ' Mb/s';
|
|
if (b > 1e3) return (b / 1e3).toFixed(1) + ' kb/s';
|
|
return (b || 0) + ' b/s';
|
|
}
|
|
|
|
// Endpoints: /api/v1/hub/netstats/summary /api/v1/hub/netstats/series
|
|
async function loadNetstats() {
|
|
const sum = await Jhub('/netstats/summary');
|
|
const kpi = document.getElementById('ns-kpi');
|
|
document.getElementById('ns-stale').style.display = (sum && sum.stale) ? 'block' : 'none';
|
|
if (!sum || sum.__error) { kpi.innerHTML = `<span class="k">err</span><span class="v">${(sum && sum.__error) || 'no data'}</span>`; return; }
|
|
const cats = sum.categories || {};
|
|
kpi.innerHTML = `<span class="k">Drops réseau (total)</span> <span class="v">${sum.network_drops || 0}</span>`
|
|
+ Object.keys(cats).map(c => ` <span class="k">${c}</span> <span class="v">${(cats[c].packets) || 0}</span>`).join('');
|
|
|
|
const ser = await Jhub('/netstats/series?window=86400&step=300');
|
|
const tp = document.getElementById('ns-throughput');
|
|
const dr = document.getElementById('ns-drops');
|
|
if (!ser || ser.__error) { tp.textContent = 'no data'; dr.textContent = ''; return; }
|
|
const inb = ser.in_bps || {}, outb = ser.out_bps || {};
|
|
tp.innerHTML = Object.keys(inb).map(ifc =>
|
|
`<div class="row"><b>${ifc}</b> ↓ ${fmtBps((inb[ifc].slice(-1)[0] || [0, 0])[1])} ${sparkline(inb[ifc], '#00d4ff')}`
|
|
+ ` ↑ ${fmtBps(((outb[ifc] || []).slice(-1)[0] || [0, 0])[1])} ${sparkline(outb[ifc] || [], '#c9a84c')}</div>`).join('') || '<span style="opacity:.5">—</span>';
|
|
const drops = ser.drops || {};
|
|
dr.innerHTML = Object.keys(drops).map(c =>
|
|
`<div class="row"><b>${c}</b> ${sparkline(drops[c], '#e63946')}</div>`).join('') || '<span style="opacity:.5">—</span>';
|
|
}
|
|
|
|
async function refreshAll() {
|
|
await Promise.all([loadCfg(), loadClients(), loadHealth(), loadMetrics()]);
|
|
}
|
|
|
|
// Deep-link : open the tab named in the URL hash on load.
|
|
const initial = (location.hash || '').replace('#', '');
|
|
if (['overview','clients','filtres','social','ads','reseau','config'].includes(initial)) switchTab(initial);
|
|
|
|
refreshAll();
|
|
setInterval(() => {
|
|
// Only refresh the currently-visible tab's live data.
|
|
const active = document.querySelector('.tab.active')?.dataset.tab;
|
|
if (active === 'overview') { loadHealth(); loadMetrics(); }
|
|
else if (active === 'clients') loadClients();
|
|
else if (active === 'social') loadSocial();
|
|
}, 10000);
|
|
</script>
|
|
</body>
|
|
</html>
|