Files
ja4-platform/services/dashboard/backend/templates/base.html
toto a108814a56 feat: roadmap détection bots §2-9 — HTTP/2, cohérence, drift, flotte, Jaccard, ExIFFI, méta-learner, métriques
Étape 2 — Fingerprinting HTTP/2 dans le pipeline ML :
- Ajout du dictionnaire dict_browser_h2 (11 familles de navigateurs) dans 05_aggregation_tables.sql
- Ajout du CTE h2_agg et 4 features HTTP/2 dans 07_ai_features_view.sql :
  h2_settings_known, h2_pseudo_order_match, h2_ja4_coherence, h2_settings_rare
- Calcul du fingerprint_coherence_score (5 axes pondérés) dans la vue
- Ajout du 6e axe axis_h2_coherence dans browser.py (poids rééquilibrés)
- browser_h2.csv : 11 fingerprints Akamai → famille navigateur

Étape 3 — Pré-filtre de cohérence sur la baseline humaine :
- pipeline.py exclut les sessions avec fingerprint_coherence_score < seuil de la baseline d'entraînement
- FINGERPRINT_COHERENCE_THRESHOLD configurable via env (défaut 0.25)
- Log des sessions exclues pour analyse SOC

Étape 4 — Détection de drift améliorée :
- scoring.py : passage de 5 à 9 quantiles (p5…p95)
- Ajout de la divergence KL en complément du test KS
- Détection de drift adversarial (≥80% des features dérivent dans la même direction)
- Split temporel strict pour la validation

Étape 5 — Graphe bipartite JA4×ASN (§5.2) :
- fleet.py : détection de flottes via NetworkX + Louvain (imports optionnels)
- enrich_with_fleet_score() : ajout fleet_score + fleet_campaign_flag au DataFrame
- cycle.py : appel après preprocess_df avec log du nombre de sessions en flotte
- SQL migration 05_fleet_metrics_tables.sql : table fleet_detections (TTL 7j)
- Dashboard : /fleet + /api/fleet (communautés détectées) + template fleet.html

Étape 6 — Cross-domain Jaccard §5.8 :
- 12_thesis_features.sql : CTE jaccard_paths → cross_domain_path_similarity
- Signal : même chemins (/admin, /wp-login) sur plusieurs hosts = scanner

Étape 7 — ExIFFI + erreurs AE par feature :
- scoring.py : compute_exiffi_importance() par permutation, compute_ae_feature_errors()
- pipeline.py : calcul ExIFFI sur X_test, mapping index → dict pour anomalies
- build_reason() enrichi avec exiffi_top quand SHAP inactif

Étape 8 — Méta-learner pour la pondération de l'ensemble :
- scoring.py : classe MetaLearner (LogisticRegression, fallback poids fixes <1000 labels)
- Collecte des labels depuis le cycle courant (known_bots, légitimes, Anubis)
- pipeline.py : remplacement des poids fixes par MetaLearner.predict()

Étape 9 — Métriques de performance et monitoring :
- metrics.py : record_cycle_metrics() — taux anomalie, drift, corrélation, latence
- SQL migration 05_fleet_metrics_tables.sql : table ml_performance_metrics (TTL 90j)
- Dashboard : /health + /api/health + template health.html
- cycle.py : appel record_cycle_metrics en fin de cycle (Complet + Applicatif)

Tests : 36/36 bot-detector tests passent

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 00:11:35 +02:00

400 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}JA4 SOC Dashboard{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: { 50:'#eef2ff',100:'#e0e7ff',500:'#6366f1',600:'#4f46e5',700:'#4338ca',900:'#312e81' },
surface: { 800:'#1e293b', 900:'#0f172a', 950:'#020617' },
},
fontFamily: { sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'] },
}
}
}
</script>
<style>
body { font-family: 'Inter', system-ui, sans-serif; }
/* ── Threat badges ── */
.threat-critical { color: #ef4444; font-weight: 700; }
.threat-high { color: #f97316; font-weight: 600; }
.threat-medium { color: #eab308; }
.threat-low { color: #22c55e; }
.threat-normal { color: #6b7280; }
/* ── KPI cards ── */
.kpi-card { background: rgba(17,24,39,0.8); border-radius: 0.75rem; padding: 1rem; border: 1px solid #1f2937; backdrop-filter: blur(8px); }
/* ── Data table ── */
.data-table { width: 100%; font-size: 0.875rem; text-align: left; }
.data-table th { padding: 0.625rem 0.75rem; background: #111827; color: #9ca3af; font-weight: 500; border-bottom: 1px solid #1f2937; position: sticky; top: 0; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
.data-table td { padding: 0.5rem 0.75rem; border-bottom: 1px solid rgba(31,41,55,0.5); color: #d1d5db; }
.data-table tbody tr:hover { background: rgba(31,41,55,0.4); }
.data-table tbody tr { cursor: pointer; transition: background-color 0.15s, color 0.15s; }
/* ── Badges ── */
.badge { display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
.badge-critical { background: rgba(239,68,68,0.2); color: #f87171; }
.badge-high { background: rgba(249,115,22,0.2); color: #fb923c; }
.badge-medium { background: rgba(234,179,8,0.2); color: #facc15; }
.badge-low { background: rgba(34,197,94,0.2); color: #4ade80; }
.badge-normal { background: rgba(107,114,128,0.2); color: #9ca3af; }
.badge-known { background: rgba(59,130,246,0.2); color: #60a5fa; }
/* ── Filter buttons ── */
.filter-btn { padding: 0.375rem 0.75rem; font-size: 0.75rem; border-radius: 0.5rem; border: 1px solid #374151; color: #9ca3af; transition: border-color 0.15s, color 0.15s; cursor: pointer; background: transparent; }
.filter-btn:hover { border-color: #6366f1; color: #6366f1; }
.filter-btn.active { border-color: #6366f1; background: rgba(99,102,241,0.2); color: #6366f1; }
/* ── Section card ── */
.section-card { background: rgba(17,24,39,0.8); border-radius: 0.75rem; border: 1px solid #1f2937; backdrop-filter: blur(8px); }
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.25rem; border-bottom: 1px solid #1f2937; }
.section-title { font-size: 0.875rem; font-weight: 600; color: #e5e7eb; display: flex; align-items: center; gap: 0.5rem; }
.section-body { padding: 1.25rem; }
/* ── Sidebar ── */
.nav-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.75rem; border-radius: 0.5rem; color: #9ca3af; transition: color 0.15s, background 0.15s; font-size: 0.875rem; cursor: pointer; text-decoration: none; }
.nav-item:hover { color: white; background: rgba(31,41,55,0.6); }
.nav-item.active { background: rgba(79,70,229,0.2); color: #818cf8; border-left: 2px solid #6366f1; }
.nav-group-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #4b5563; padding: 1rem 0.75rem 0.25rem; }
</style>
<style>
/* ── Raw CSS (no @apply) for reliable rendering ── */
.sidebar { width: 220px; transition: width 0.2s ease; }
.sidebar.collapsed { width: 56px; }
.sidebar.collapsed .nav-text { display: none; }
.sidebar.collapsed .nav-group-title { display: none; }
.sidebar.collapsed .sidebar-logo-text { display: none; }
/* ── Infobulles (hover tooltips) ── */
.doc-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 50%; font-size: 10px;
color: #4b5563; cursor: help; transition: all 0.15s;
vertical-align: middle; margin-left: 4px; flex-shrink: 0;
}
.doc-btn:hover { color: #d1d5db; background: #374151; }
.doc-panel {
display: none; position: absolute; z-index: 60;
top: calc(100% + 10px); right: -8px; width: 300px;
padding: 12px 14px; background: #111827; border: 1px solid #374151;
border-radius: 10px; box-shadow: 0 16px 48px rgba(0,0,0,0.6);
font-size: 11px; line-height: 1.6; color: #d1d5db;
pointer-events: auto;
}
.doc-panel::before {
content: ''; position: absolute; top: -5px; right: 14px;
width: 10px; height: 10px; background: #111827;
border-left: 1px solid #374151; border-top: 1px solid #374151;
transform: rotate(45deg);
}
.doc-panel h4 { color: white; font-weight: 600; font-size: 12px; margin: 0 0 6px; }
.doc-panel p { margin: 0 0 5px; }
.doc-panel .doc-source { color: #6b7280; font-style: italic; margin-top: 6px; padding-top: 6px; border-top: 1px solid #1f2937; font-size: 10px; }
/* Hover: show on parent hover */
.relative:has(> .doc-btn):hover > .doc-panel,
.doc-panel.show {
display: block; animation: ttIn 0.12s ease-out;
}
/* Mobile: keep tap toggle via JS */
@keyframes ttIn { from { opacity:0; transform:translateY(-3px); } to { opacity:1; transform:translateY(0); } }
/* ── Animations ── */
@keyframes fadeUp { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
.animate-in { animation: fadeUp 0.3s ease-out both; }
.kpi-card { animation: fadeUp 0.3s ease-out both; }
.kpi-card:nth-child(1) { animation-delay: 0ms; }
.kpi-card:nth-child(2) { animation-delay: 40ms; }
.kpi-card:nth-child(3) { animation-delay: 80ms; }
.kpi-card:nth-child(4) { animation-delay: 120ms; }
.kpi-card:nth-child(5) { animation-delay: 160ms; }
.kpi-card:nth-child(6) { animation-delay: 200ms; }
@keyframes pulse-dot { 0%,100%{opacity:1;} 50%{opacity:0.4;} }
.live-dot { width:6px; height:6px; background:#22c55e; border-radius:50%; display:inline-block; animation: pulse-dot 1.5s ease-in-out infinite; }
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #4b5563; }
</style>
{% block head %}{% endblock %}
</head>
<body class="bg-gray-950 text-gray-200 min-h-screen flex">
<!-- ═══ Sidebar Navigation ═══ -->
<aside id="sidebar" class="sidebar fixed top-0 left-0 h-screen bg-gray-950 border-r border-gray-800 flex flex-col z-50 overflow-hidden">
<!-- Logo -->
<div class="flex items-center gap-2 px-3 h-14 border-b border-gray-800 shrink-0">
<button onclick="toggleSidebar()" class="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-sm hover:bg-brand-500 transition-colors shrink-0">J4</button>
<span class="sidebar-logo-text text-white font-semibold text-sm whitespace-nowrap">JA4 SOC</span>
</div>
<!-- Nav groups -->
<nav class="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
<div class="nav-group-title">Surveillance</div>
<a href="/" class="nav-item {% if active_page == 'overview' %}active{% endif %}" title="Vue d'ensemble">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
<span class="nav-text">Overview</span>
</a>
<a href="/detections" class="nav-item {% if active_page == 'detections' %}active{% endif %}" title="Détections d'anomalies">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
<span class="nav-text">Détections</span>
</a>
<a href="/scores" class="nav-item {% if active_page == 'scores' %}active{% endif %}" title="Scores ML">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
<span class="nav-text">Scores</span>
</a>
<a href="/campaigns" class="nav-item {% if active_page == 'campaigns' %}active{% endif %}" title="Campagnes de bots (clusters)">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span class="nav-text">Campagnes</span>
</a>
<a href="/tactics" class="nav-item {% if active_page == 'tactics' %}active{% endif %}" title="Tactiques d'attaque détectées">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="nav-text">Tactiques</span>
</a>
<a href="/fleet" class="nav-item {% if active_page == 'fleet' %}active{% endif %}" title="Flottes JA4×ASN (§5.2)">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="nav-text">Flottes</span>
</a>
<div class="nav-group-title">Investigation</div>
<a href="/traffic" class="nav-item {% if active_page == 'traffic' %}active{% endif %}" title="Logs HTTP bruts">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7c-2 0-3 1-3 3z"/><path stroke-linecap="round" stroke-width="2" d="M9 12h6M9 8h6M9 16h3"/></svg>
<span class="nav-text">Trafic</span>
</a>
<a href="/network" class="nav-item {% if active_page == 'network' %}active{% endif %}" title="Analyse réseau">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
<span class="nav-text">Réseau</span>
</a>
<a href="/features" class="nav-item {% if active_page == 'features' %}active{% endif %}" title="Features ML">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
<span class="nav-text">Features</span>
</a>
<div class="nav-group-title">Opérations</div>
<a href="/models" class="nav-item {% if active_page == 'models' %}active{% endif %}" title="Modèles ML">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
<span class="nav-text">Modèles</span>
</a>
<a href="/classify" class="nav-item {% if active_page == 'classify' %}active{% endif %}" title="Classification SOC">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
<span class="nav-text">Classifier</span>
</a>
<a href="/reflists" class="nav-item {% if active_page == 'reflists' %}active{% endif %}" title="Listes de référence CSV / Dictionnaires">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.58 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.58 4 8 4s8-1.79 8-4M4 7c0-2.21 3.58-4 8-4s8 1.79 8 4m0 5c0 2.21-3.58 4-8 4s-8-1.79-8-4"/></svg>
<span class="nav-text">Listes réf.</span>
</a>
<a href="/health" class="nav-item {% if active_page == 'health' %}active{% endif %}" title="Santé du pipeline ML (§9)">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
<span class="nav-text">Santé ML</span>
</a>
</nav>
<!-- Footer -->
<div class="px-3 py-3 border-t border-gray-800 shrink-0">
<span class="flex items-center gap-2 text-[10px] text-gray-500">
<span class="live-dot"></span>
<span id="clock" class="nav-text"></span>
</span>
</div>
</aside>
<!-- ═══ Main Content ═══ -->
<div id="main-wrap" class="flex-1 min-h-screen" style="margin-left:220px; transition: margin-left 0.2s ease;">
<!-- Page header -->
<header class="sticky top-0 z-40 bg-gray-950/90 backdrop-blur-md border-b border-gray-800">
<div class="flex items-center h-12 px-4 lg:px-6">
<h1 class="text-sm lg:text-base font-semibold text-gray-100 truncate">{% block page_title %}{% endblock %}</h1>
<div class="flex-1"></div>
{% block header_actions %}{% endblock %}
</div>
</header>
<main class="px-3 py-4 lg:px-5 lg:py-5 xl:px-6">
{% block content %}{% endblock %}
</main>
</div>
<script>
// ── Sidebar toggle ──
function toggleSidebar() {
const sb = document.getElementById('sidebar');
const mw = document.getElementById('main-wrap');
sb.classList.toggle('collapsed');
mw.style.marginLeft = sb.classList.contains('collapsed') ? '56px' : '220px';
}
// Auto-collapse on narrow screens
if (window.innerWidth < 1024) toggleSidebar();
// ── Keyboard shortcuts ──
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
const routes = {'1':'/', '2':'/detections', '3':'/scores', '4':'/campaigns',
'5':'/traffic', '6':'/network', '7':'/features', '8':'/models', '9':'/classify'};
if (e.altKey && routes[e.key]) { e.preventDefault(); window.location = routes[e.key]; }
if (e.key === 'b' && e.altKey) { e.preventDefault(); toggleSidebar(); }
});
// ── Clock ──
function updateClock() {
const el = document.getElementById('clock');
if (el) el.textContent = new Date().toLocaleString('fr-FR');
}
updateClock(); setInterval(updateClock, 1000);
// ── Doc tooltip (mobile tap fallback) ──
function docToggle(btn) {
const panel = btn.nextElementSibling;
document.querySelectorAll('.doc-panel.show').forEach(p => { if (p !== panel) p.classList.remove('show'); });
panel.classList.toggle('show');
// Auto-dismiss after 8s on mobile
if (panel.classList.contains('show')) {
clearTimeout(panel._timer);
panel._timer = setTimeout(() => panel.classList.remove('show'), 8000);
}
}
document.addEventListener('click', e => {
if (!e.target.closest('.doc-btn') && !e.target.closest('.doc-panel'))
document.querySelectorAll('.doc-panel.show').forEach(p => p.classList.remove('show'));
});
// ── HTML escape ──
function esc(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
// ── Number formatting ──
function fmtNum(n) {
if (n == null) return '—';
n = Number(n);
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
if (n >= 1000) return (n/1000).toFixed(1) + 'K';
return n.toLocaleString('fr-FR');
}
function fmtPct(n) { return n == null ? '—' : (Number(n)*100).toFixed(1)+'%'; }
// ── Threat helpers ──
function threatBadge(level) {
const map = {
'CRITICAL':'badge-critical','HIGH':'badge-high','MEDIUM':'badge-medium',
'LOW':'badge-low','NORMAL':'badge-normal','KNOWN_BOT':'badge-known',
'ANUBIS_DENY':'badge-critical','LEGITIMATE_BROWSER':'badge-low'
};
return `<span class="badge ${map[level]||'badge-normal'}">${escapeHtml(level||'')}</span>`;
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
// ── Link formatters (all sanitized) ──
function fmtIP(ip) {
if (!ip) return '';
let s = String(ip).replace('::ffff:','');
return `<a href="/ip/${encodeURIComponent(s)}" class="text-brand-500 hover:underline font-mono text-xs">${escapeHtml(s)}</a>`;
}
function fmtScore(v) {
let n = parseFloat(v);
if (isNaN(n)) return '—';
let color = n > 0.7 ? 'text-red-400' : n > 0.4 ? 'text-orange-400' : n > 0.1 ? 'text-yellow-400' : 'text-green-400';
return `<span class="${color} font-mono">${n.toFixed(4)}</span>`;
}
function fmtASN(org) {
if (!org) return '';
return `<a href="/network?asn_org=${encodeURIComponent(org)}" onclick="event.stopPropagation()" class="text-blue-400 hover:underline cursor-pointer">${escapeHtml(org)}</a>`;
}
function fmtCountry(cc) {
if (!cc) return '';
const flags = {'FR':'🇫🇷','DE':'🇩🇪','NL':'🇳🇱','GB':'🇬🇧','ES':'🇪🇸','US':'🇺🇸','RU':'🇷🇺','IT':'🇮🇹','JP':'🇯🇵','CN':'🇨🇳','KR':'🇰🇷','BR':'🇧🇷','AU':'🇦🇺','CA':'🇨🇦','IN':'🇮🇳','SG':'🇸🇬','SE':'🇸🇪','FI':'🇫🇮','IE':'🇮🇪','CH':'🇨🇭'};
return `<a href="/detections?country_code=${encodeURIComponent(cc)}" onclick="event.stopPropagation()" class="hover:underline cursor-pointer">${flags[cc]||'🏳️'} ${escapeHtml(cc)}</a>`;
}
function fmtJA4(ja4) {
if (!ja4) return '';
return `<a href="/ja4/${encodeURIComponent(ja4)}" onclick="event.stopPropagation()" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]" title="${escapeHtml(ja4)}">${escapeHtml(ja4.substring(0,22))}…</a>`;
}
function fmtJA4Full(ja4) {
if (!ja4) return '';
return `<a href="/ja4/${encodeURIComponent(ja4)}" onclick="event.stopPropagation()" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]">${escapeHtml(ja4)}</a>`;
}
function fmtBotName(name) {
if (!name) return '';
return `<a href="/detections?bot_name=${encodeURIComponent(name)}" onclick="event.stopPropagation()" class="text-cyan-400 hover:underline cursor-pointer">${escapeHtml(name)}</a>`;
}
function fmtThreatLink(level) {
if (!level) return '';
return `<a href="/detections?threat_level=${encodeURIComponent(level)}" class="cursor-pointer">${threatBadge(level)}</a>`;
}
function fmtLabel(label) {
if (!label) return '';
const colors = {human:'text-green-400 bg-green-500/10',datacenter:'text-red-400 bg-red-500/10',hosting:'text-orange-400 bg-orange-500/10',cdn:'text-cyan-400 bg-cyan-500/10'};
return `<span class="px-1.5 py-0.5 rounded text-xs ${colors[label]||'text-gray-400 bg-gray-500/10'}">${escapeHtml(label)}</span>`;
}
function fmtDuration(seconds) {
if (!seconds || seconds < 0) return '—';
if (seconds < 60) return Math.round(seconds) + 's';
if (seconds < 3600) return Math.round(seconds/60) + 'min';
return (seconds/3600).toFixed(1) + 'h';
}
function fmtAgo(dateStr) {
if (!dateStr) return '—';
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
if (diff < 60) return 'à l\'instant';
if (diff < 3600) return Math.round(diff/60) + ' min';
if (diff < 86400) return Math.round(diff/3600) + 'h';
return Math.round(diff/86400) + 'j';
}
function fmtDate(d) {
if (!d) return '—';
return String(d).substring(0, 16).replace('T', ' ');
}
// ── ECharts helpers ──
const EC_COLORS = ['#6366f1','#22c55e','#f97316','#ef4444','#3b82f6','#eab308','#ec4899','#14b8a6','#8b5cf6','#f43f5e'];
const EC_TEXT = '#9ca3af';
const EC_GRID = '#374151';
function ecBase(overrides) {
return Object.assign({
backgroundColor: 'transparent',
textStyle: { color: EC_TEXT, fontFamily: 'Inter, system-ui, sans-serif', fontSize: 11 },
animation: true, animationDuration: 400,
}, overrides);
}
function ecTooltip(extra) {
return Object.assign({
backgroundColor: '#1f2937', borderColor: '#374151',
textStyle: { color: '#e5e7eb', fontSize: 11 },
confine: true,
}, extra);
}
function ecGrid(extra) {
return Object.assign({ left: 50, right: 20, top: 30, bottom: 30 }, extra);
}
// ── Chart resize on window resize ──
window.addEventListener('resize', () => {
document.querySelectorAll('[_echarts_instance_]').forEach(el => {
echarts.getInstanceByDom(el)?.resize();
});
});
// ── Generic table builder ──
function buildTable(tbody, rows, cols) {
tbody.innerHTML = rows.map(r =>
'<tr>' + cols.map(c => `<td>${c.fmt ? c.fmt(r[c.key], r) : escapeHtml(r[c.key]??'')}</td>`).join('') + '</tr>'
).join('');
}
// ── Doc helper: generates an ⓘ tooltip button + panel ──
function docHTML(title, body, source) {
return `<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn" aria-label="Aide">ⓘ</button><div class="doc-panel"><h4>${escapeHtml(title)}</h4>${body}<p class="doc-source">Source : ${escapeHtml(source)}</p></div></span>`;
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>