feat(toolbox): WebUI sub-tab nav + remove redundant kbin /admin/ inline UI (ref #513)

Consolidates the two toolbox admin surfaces onto the canonical
admin.gk2.secubox.in/toolbox/ WebUI.

  - www/toolbox/index.html: 5-tab nav (Vue d'ensemble / Clients /
    Filtres MITM / Cartographie sociale / Config). Lazy-load per tab,
    live refresh only polls the visible tab, URL-hash deep-links.
    Clients tab folds in the R0-R3 level switcher from the old kbin
    admin UI. New Cartographie sociale tab surfaces the Phase 11
    operator aggregate (KPI + top trackers + anonymized clients).
  - api.py: DELETED the inline kbin /admin/ HTML route (admin_index,
    ~230 lines). All /admin/* JSON API routes preserved.
  - conf/landing.html.j2: removed the Admin quicknav icon.
  - debian/changelog: 2.6.1 -> 2.6.2.

Live on gk2: /toolbox/ 200 (19853 bytes, 5 tabs), kbin /admin/ now 404
by design, social-aggregate shows 225 trackers / 3 clients, all tab
endpoints (clients/rich, filter-control/list, social-aggregate) 200.
This commit is contained in:
CyberMind-FR 2026-06-10 12:50:38 +02:00
parent 78ed66c7cd
commit 39ec84eaed
4 changed files with 236 additions and 271 deletions

View File

@ -86,9 +86,6 @@ a:hover{text-decoration:underline}
<a href="/wg/qr.png" class=qi title="QR profil WireGuard">
<span class=qi-emoji>📱</span><span class=qi-label>QR profil</span>
</a>
<a href="/admin/" class=qi title="Admin webui">
<span class=qi-emoji>🛠️</span><span class=qi-label>Admin</span>
</a>
<a href="https://github.com/CyberMind-FR/secubox-deb/wiki/R3-WireGuard-install" class=qi title="Wiki R3 multi-OS">
<span class=qi-emoji>📖</span><span class=qi-label>Wiki</span>
</a>

View File

@ -1,3 +1,27 @@
secubox-toolbox (2.6.2-1~bookworm1) bookworm; urgency=medium
* ToolBox WebUI sub-tab navigation + kbin /admin/ removal (#513).
- www/toolbox/index.html : rebuilt with a 5-tab nav
(Vue d'ensemble / Clients / Filtres MITM / Cartographie
sociale / Config). Each tab lazy-loads its data; the live
refresh interval only polls the visible tab. URL hash
deep-links a tab (e.g. /toolbox/#social).
- Clients tab folds in the R0/R1/R2/R3 level switcher that
used to live only in the kbin inline admin UI (calls
/admin/clients/{mac}/level).
- Cartographie sociale tab surfaces the Phase 11 operator
aggregate (/admin/social-aggregate) : KPI tiles + top
tracker domains + anonymized client table.
- api.py : DELETED the inline kbin /admin/ HTML route
(admin_index(), ~230 lines). The canonical operator
dashboard is now admin.gk2.secubox.in/toolbox/. All
/admin/* JSON API routes are untouched.
- conf/landing.html.j2 : removed the 🛠️ Admin quicknav icon
(the page it pointed at no longer exists).
kbin.gk2.secubox.in/admin/ now 404s by design.
-- Gerald KERMA <devel@cybermind.fr> Wed, 10 Jun 2026 09:15:00 +0200
secubox-toolbox (2.6.1-1~bookworm1) bookworm; urgency=medium
* Phase 11.B (#507, parent #502) — social mapping per-client view +

View File

@ -2352,235 +2352,9 @@ async def admin_override_level(mac_hash: str, request: Request) -> dict:
"note": "nft sets not auto-updated; client must reload or operator manually adjusts nft"}
@router.get("/admin/", response_class=HTMLResponse)
@router.get("/admin", response_class=HTMLResponse)
async def admin_index() -> HTMLResponse:
"""Operator admin webUI with client list + level switcher."""
html = """<!DOCTYPE html><html lang=fr><head><meta charset=UTF-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>🛡 Admin Gondwana ToolBoX</title>
<style>:root{--bg:#0a0a0f;--bg2:#0e0e15;--phos:#00dd44;--phos-hot:#00ff55;--dim:#006622;--text:#e8e6d9;--purple:#9e76ff;--amber:#ffb347;--red:#ff4466}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Menlo,Consolas,monospace;background:var(--bg);color:var(--text);padding:1.2rem;max-width:1100px;margin:auto;line-height:1.55}
h1{color:var(--phos-hot);text-shadow:0 0 6px var(--phos);font-size:1.6rem;margin-bottom:0.4rem;letter-spacing:0.05em}
.sub{color:var(--dim);font-size:0.85rem;margin-bottom:1.2rem}
table{width:100%;border-collapse:collapse;font-size:0.85rem;background:var(--bg2);border:1px solid var(--dim)}
th,td{padding:0.5rem 0.6rem;text-align:left;border-bottom:1px solid var(--dim)}
th{color:var(--phos-hot);text-shadow:0 0 3px var(--phos);background:rgba(0,221,68,0.08)}
tr:hover{background:rgba(0,221,68,0.04)}
.chip{display:inline-block;padding:0.15rem 0.5rem;border-radius:99px;font-size:0.72rem;font-weight:bold}
.chip.r0{background:#222;color:#999}
.chip.r1{background:rgba(0,221,68,0.2);color:var(--phos-hot)}
.chip.r2{background:rgba(255,179,71,0.2);color:#ffd6a0}
.chip.r3{background:rgba(158,118,255,0.2);color:#cbb6ff}
.btn{background:var(--purple);color:#0a0a0f;padding:0.3rem 0.6rem;border:none;border-radius:3px;cursor:pointer;font-family:inherit;font-size:0.72rem;font-weight:bold;margin-right:0.2rem}
.btn:hover{background:#b598ff}
.btn.outline{background:transparent;color:var(--phos);border:1px solid var(--phos)}
code{background:#222;padding:0.1rem 0.3rem;border-radius:2px;font-size:0.75rem}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0.8rem;margin-bottom:1.5rem}
.card{background:var(--bg2);border:1px solid var(--dim);padding:0.8rem;border-radius:4px;text-align:center}
.card .v{font-size:1.6rem;color:var(--phos-hot);font-weight:bold;display:block}
.card .l{font-size:0.7rem;color:var(--dim)}
.modal-bg{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);display:none;align-items:center;justify-content:center;z-index:100}
.modal-bg.show{display:flex}
.modal{background:var(--bg2);border:1px solid var(--phos);padding:1.5rem;border-radius:4px;max-width:400px;width:90%}
.modal h2{color:var(--phos-hot);font-size:1rem;margin-bottom:0.8rem}
.modal .lvl-row{display:grid;grid-template-columns:repeat(4,1fr);gap:0.4rem;margin-bottom:0.8rem}
.modal .lvl-row button{padding:0.5rem;cursor:pointer;font-family:inherit;font-size:0.75rem;border-radius:3px}
.modal .lvl-row .r0{background:#222;color:#999;border:1px solid #444}
.modal .lvl-row .r1{background:rgba(0,221,68,0.15);color:var(--phos-hot);border:1px solid var(--phos)}
.modal .lvl-row .r2{background:rgba(255,179,71,0.15);color:#ffd6a0;border:1px solid var(--amber)}
.modal .lvl-row .r3{background:rgba(158,118,255,0.15);color:#cbb6ff;border:1px solid var(--purple)}
.adm-tabs{display:flex;gap:2px;border-bottom:1px solid var(--dim);margin-bottom:0.8rem}
.adm-tab{background:transparent;border:0;color:#888;padding:0.5rem 1rem;font-family:inherit;font-size:0.85rem;cursor:pointer;border-bottom:2px solid transparent;transition:all 0.15s}
.adm-tab.active{color:var(--phos-hot);border-bottom-color:var(--phos);background:rgba(0,221,68,0.05)}
.adm-tab:hover{color:var(--text)}
.adm-content{display:none}
.adm-content.active{display:block}
button.del-pattern{background:var(--red);color:#fff;border:0;padding:0.2rem 0.5rem;border-radius:3px;cursor:pointer;font-weight:bold;font-size:0.75rem}
</style></head><body>
<h1>🛡 Admin Gondwana ToolBoX</h1>
<p class=sub>// Console opérateur · client management · level override · mitm filtering</p>
{# Phase 6.I : tabbed sections #}
<div class=adm-tabs>
<button class="adm-tab active" data-tab=clients>👥 Clients</button>
<button class="adm-tab" data-tab=filter>🛡 Mitm filtering</button>
</div>
<div class="adm-content active" data-content=clients>
<div id=cards class=cards></div>
<table id=clients-table>
<thead><tr>
<th>Status</th><th>Device</th><th>Hash</th><th>IP</th>
<th>Level</th><th>Risk</th><th>Last seen</th><th>Actions</th>
</tr></thead>
<tbody id=clients-tbody><tr><td colspan=8>chargement</td></tr></tbody>
</table>
</div>
<div class="adm-content" data-content=filter>
<h2 style="color:var(--purple);font-size:1.1rem;margin-bottom:0.4rem">🛡 Mitm bypass whitelist</h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.8rem">
Hosts/domains qui ne sont JAMAIS déchiffrés par mitm (TLS passthrough).
Pour apps cert-pinned ou E2E. Redémarre <code>secubox-toolbox-mitm-wg</code>
après modif.
</p>
<form id=add-pattern-form style="display:flex;gap:0.5rem;margin-bottom:0.8rem">
<input type=text name=entry placeholder="ex: (.+\\.)?example\\.com" required
style="flex:1;background:#111;color:var(--text);border:1px solid #2a2a3f;padding:0.5rem;border-radius:3px;font-family:monospace;font-size:0.8rem">
<button type=submit class=btn style="background:var(--phos);padding:0.5rem 1rem"> Ajouter</button>
</form>
<table id=filter-table style="font-size:0.78rem">
<thead><tr><th>Pattern (regex)</th><th width=60>×</th></tr></thead>
<tbody id=filter-tbody><tr><td colspan=2>chargement</td></tr></tbody>
</table>
<p style="font-size:0.72rem;color:var(--dim);margin-top:0.6rem;border-left:2px solid var(--amber);padding-left:0.6rem">
📁 <code>/var/lib/secubox/toolbox/mitm-bypass.conf</code> · Source de vérité
</p>
</div>
<div id=modal class=modal-bg>
<div class=modal>
<h2>🔀 Change level <code id=modal-mh></code></h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.5rem"> Admin override : updates store only. Client must reload to sync nft.</p>
<div class=lvl-row>
<button class=r0 onclick="setLevel('r0')">🌐 R0</button>
<button class=r1 onclick="setLevel('r1')">🛡 R1</button>
<button class=r2 onclick="setLevel('r2')">🔍 R2</button>
<button class=r3 onclick="setLevel('r3')">🌐 R3</button>
</div>
<button onclick="closeModal()" class=btn style="background:transparent;color:var(--dim);border:1px solid var(--dim)">Annuler</button>
</div>
</div>
<script>
var selectedMh = null;
function openModal(mh) {
selectedMh = mh;
document.getElementById('modal-mh').textContent = mh;
document.getElementById('modal').classList.add('show');
}
function closeModal() {
document.getElementById('modal').classList.remove('show');
}
async function setLevel(lvl) {
if (!selectedMh) return;
var fd = new FormData();
fd.append('level', lvl);
var r = await fetch('/admin/clients/'+selectedMh+'/level', {method:'POST', body:fd});
if (r.ok) {
closeModal();
loadClients();
} else {
alert('Erreur: '+r.status);
}
}
async function loadClients() {
var r = await fetch('/admin/clients/rich');
if (!r.ok) {
document.getElementById('clients-tbody').innerHTML = '<tr><td colspan=8>Erreur '+r.status+'</td></tr>';
return;
}
var data = await r.json();
var tbody = document.getElementById('clients-tbody');
tbody.innerHTML = '';
data.clients.forEach(function(c){
var lvlChip = '<span class="chip '+c.level+'">'+c.level_emoji+' '+c.level.toUpperCase()+'</span>';
var dt = new Date(c.last_seen*1000).toISOString().substring(11,16);
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+c.status_emoji+' '+c.status_label+'</td>'+
'<td>'+c.device_emoji+'</td>'+
'<td><code>'+c.mac_hash.substring(0,12)+'…</code></td>'+
'<td>'+(c.ip||'?')+'</td>'+
'<td>'+lvlChip+'</td>'+
'<td>'+c.risk_emoji+' '+c.score+'</td>'+
'<td>'+dt+'</td>'+
'<td><button class=btn onclick="openModal(\\''+c.mac_hash+'\\')">🔀 Override</button>'+
'<a href="/admin/clients/'+c.mac_hash+'/report" class="btn outline">📄 PDF</a></td>';
tbody.appendChild(tr);
});
// KPI cards
var counts = {r0:0,r1:0,r2:0,r3:0,actif:0,risk_low:0,risk_mh:0};
data.clients.forEach(function(c){
counts[c.level] = (counts[c.level]||0)+1;
if (c.status_label==='actif') counts.actif++;
if (c.score < 30) counts.risk_low++;
if (c.score >= 30) counts.risk_mh++;
});
document.getElementById('cards').innerHTML =
'<div class=card><span class=v>'+data.count+'</span><span class=l>Total clients</span></div>'+
'<div class=card><span class=v>'+counts.actif+'</span><span class=l>🟢 Actifs (5min)</span></div>'+
'<div class=card><span class=v>'+counts.r1+'</span><span class=l>🛡 R1</span></div>'+
'<div class=card><span class=v>'+counts.r2+'</span><span class=l>🔍 R2</span></div>'+
'<div class=card><span class=v>'+counts.r3+'</span><span class=l>🌐 R3 WG</span></div>'+
'<div class=card><span class=v>'+counts.risk_low+'</span><span class=l>🟢 Risque LOW</span></div>';
}
loadClients();
setInterval(loadClients, 15000);
// Tabs (Phase 6.I)
document.querySelectorAll('.adm-tab').forEach(function(t){
t.addEventListener('click', function(){
var tn = this.dataset.tab;
document.querySelectorAll('.adm-tab').forEach(function(x){x.classList.remove('active');});
this.classList.add('active');
document.querySelectorAll('.adm-content').forEach(function(x){x.classList.remove('active');});
document.querySelector('[data-content='+tn+']').classList.add('active');
if (tn === 'filter') loadFilter();
});
});
// Filter Control (integrated tab)
async function loadFilter(){
var r = await fetch('/admin/filter-control/list');
var tb = document.getElementById('filter-tbody');
if (!r.ok) { tb.innerHTML = '<tr><td colspan=2>Erreur '+r.status+'</td></tr>'; return; }
var d = await r.json();
if (!d.patterns || !d.patterns.length) {
tb.innerHTML = '<tr><td colspan=2 style="color:#666;text-align:center">Aucune entrée</td></tr>';
return;
}
tb.innerHTML = '';
d.patterns.forEach(function(p){
var tr = document.createElement('tr');
var td1 = document.createElement('td');
var code = document.createElement('code');
code.textContent = p;
td1.appendChild(code);
var td2 = document.createElement('td');
var btn = document.createElement('button');
btn.className = 'del-pattern';
btn.textContent = '×';
btn.onclick = function(){ delPattern(p); };
td2.appendChild(btn);
tr.appendChild(td1); tr.appendChild(td2);
tb.appendChild(tr);
});
}
async function delPattern(p){
var fd = new FormData(); fd.append('entry', p);
var r = await fetch('/admin/filter-control/remove', {method:'POST', body:fd});
if (r.ok || r.status === 303) loadFilter();
}
document.getElementById('add-pattern-form').addEventListener('submit', async function(ev){
ev.preventDefault();
var entry = ev.target.entry.value.trim();
if (!entry) return;
var fd = new FormData(); fd.append('entry', entry);
var r = await fetch('/admin/filter-control/add', {method:'POST', body:fd});
if (r.ok || r.status === 303) {
ev.target.entry.value = '';
loadFilter();
}
});
</script>
</body></html>"""
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
# Phase 11.B+ (#513) — the inline kbin /admin/ HTML admin UI was removed.
# The canonical operator dashboard is admin.gk2.secubox.in/toolbox/ (sub-tab
# WebUI in www/toolbox/index.html). All /admin/* JSON API routes below stay.
@router.get("/admin/filter-control/list")

View File

@ -2,7 +2,7 @@
<!--
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)
SecuBox-Deb :: ToolBoX admin dashboard (P31 light skin) — sub-tab nav (#513)
-->
<html lang="fr">
<head>
@ -14,14 +14,22 @@
<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;--bloom-text:0 0 2px var(--p31-peak),0 0 6px var(--p31-peak);--bloom-soft:0 0 6px var(--p31-peak)}
: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: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}
/* 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)}
@ -32,13 +40,23 @@
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}
.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 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>
@ -53,39 +71,107 @@
<strong>R2 actif :</strong> TLS-break opt-in. Voir #475 / #474 pour la doctrine.
</div>
<div class="toolbar">
<button onclick="refreshAll()">🔁 Refresh</button>
<button onclick="window.open('/api/v1/toolbox/admin/config', '_blank')">⚙ Config TOML</button>
</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="config" onclick="switchTab('config')">⚙ Config</button>
</nav>
<div class="grid">
<!-- 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 class="card">
<h2>♥ Liveness</h2>
<div class="kv" id="health"><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>
</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>📊 Live metrics (24h)</h2>
<div class="kv" id="metrics"><span class="k">loading…</span><span class="v"></span></div>
<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>
<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" 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>
<!-- 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>
<div class="card">
<h2>♥ Liveness</h2>
<div class="kv" id="health"><span class="k">loading…</span><span class="v"></span></div>
</div>
<div class="card" style="grid-column:1/-1">
<h2>👥 Clients actifs</h2>
<div id="clients"><div class="empty">loading…</div></div>
</div>
<div class="card" style="grid-column:1/-1;display:none" id="client-detail-card">
<h2 id="detail-title">🔍 Détails client</h2>
<div id="client-detail"></div>
</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();
location.hash = name;
}
async function J(path) {
try {
const r = await fetch(API + path, { credentials: 'same-origin' });
@ -111,24 +197,50 @@ async function loadCfg() {
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');
const d = await J('/admin/clients/rich');
const el = document.getElementById('clients');
if (d.__error) { el.innerHTML = `<div class="empty">${d.__error}</div>`; return; }
if (!d.length) { el.innerHTML = '<div class="empty">no clients</div>'; return; }
let html = '<table><thead><tr><th>MAC (hash)</th><th>IP</th><th>state</th><th>score</th><th>last</th><th>Actions</th></tr></thead><tbody>';
for (const c of d) {
const ago = Math.round((Date.now()/1000 - c.last_seen) / 60);
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; }
let html = '<table><thead><tr><th>MAC (hash)</th><th>IP</th><th>state</th><th>niveau</th><th>score</th><th>last</th><th>Actions</th></tr></thead><tbody>';
for (const c of rows) {
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}</td>
<td><span class="state-${c.state}">${c.state}</span></td>
<td>${c.score}</td>
<td>${ago}m</td>
<td>${c.ip || '—'}</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 href="${API}/admin/clients/${c.mac_hash}/report" target="_blank" style="color:var(--p31-peak);text-decoration:underline;font-size:0.8rem">PDF</a>
<a class="link" href="${API}/admin/clients/${c.mac_hash}/report" target="_blank" style="font-size:0.8rem">PDF</a>
·
<a href="javascript:void(0)" onclick="loadClientDetail('${c.mac_hash}')" style="color:var(--p31-peak);text-decoration:underline;font-size:0.8rem">Events</a>
<a class="link" href="javascript:void(0)" onclick="loadClientDetail('${c.mac_hash}')" style="font-size:0.8rem">Events</a>
</td>
</tr>`;
}
@ -162,6 +274,7 @@ async function loadClientDetail(macHash) {
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; }
@ -175,7 +288,7 @@ async function loadClientDetail(macHash) {
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 style="font-size:0.78rem">${detail}</code></td></tr>`;
recentHtml += `<tr><td>${t}</td><td><b>${e.source}</b></td><td><code>${detail}</code></td></tr>`;
}
recentHtml += '</tbody></table>';
}
@ -184,7 +297,7 @@ async function loadClientDetail(macHash) {
<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 href="${API}/admin/clients/${macHash}/report" target="_blank" style="color:var(--p31-peak);text-decoration:underline">⬇ Télécharger PDF complet</a>
<a class="link" href="${API}/admin/clients/${macHash}/report" target="_blank">⬇ Télécharger PDF complet</a>
</p>
`;
}
@ -201,11 +314,68 @@ async function loadHealth() {
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; }
el.innerHTML = 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 : '';
return `<li><code>${host}</code>${src ? `<span style="color:var(--p31-dim);font-size:0.72rem">${src}</span>` : ''}</li>`;
}).join('');
}
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');
if (agg.__error) {
kpi.innerHTML = `<span class="k">err</span><span class="v">${agg.__error}</span>`;
trk.innerHTML = cli.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 || [];
trk.innerHTML = td.length
? '<table><thead><tr><th>Tracker domain</th><th>hits</th><th>clients</th></tr></thead><tbody>' +
td.map(r => `<tr><td><code>${r.tracker_domain}</code></td><td>${r.hits}</td><td>${r.clients}</td></tr>`).join('') +
'</tbody></table>'
: '<div class="empty">aucun tracker dans la fenêtre</div>';
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.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>';
}
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','config'].includes(initial)) switchTab(initial);
refreshAll();
setInterval(refreshAll, 10000);
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>