Files
ja4-platform/services/dashboard/backend/templates/base.html
toto 9c308747bd feat(dashboard): page Browser Signature Detection (/browsers)
Nouvelle page dédiée à l'analyse passive des signatures navigateur (§4) :

API — GET /api/browsers :
  Requête view_ai_features_1h pour :
  - Compteurs globaux (total, sessions_with_h2, matched, mismatch %)
  - Distribution h2_dict_family (Chrome/Firefox/Safari/Edge)
  - Répartition des signaux WINDOW_UPDATE (chrome/firefox/safari/absent/autre)
  - Mismatch TLS↔H2 par famille JA4 (total + count + %)
  - Top 20 sessions suspectes (tls_h2_family_mismatch=1, triées par hits)

Page /browsers :
  - 6 KPI header (sessions, avec H2, famille connue, taux match, mismatch, % mismatch)
  - Doc banner expliquant browser_matcher §4 et le mode DUAL_MODE
  - Donut : familles H2 (dict_browser_h2 lookup)
  - Bar horizontal : WINDOW_UPDATE signals par famille
  - Bar groupé + ligne : mismatch TLS↔H2 par famille JA4 (count + %)
  - Table : top 20 imposteurs potentiels avec IP cliquable, pseudo-order, cohérence
  - Mini-KPIs : ordres pseudo-headers Chrome/Safari, Firefox, inconnu, PRIORITY frames
  - Lien nav 'Navigateurs' dans le groupe Surveillance de base.html

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

404 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>
<a href="/browsers" class="nav-item {% if active_page == 'browsers' %}active{% endif %}" title="Signatures navigateur H2 (§4)">
<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">Navigateurs</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>