Files
ja4-platform/services/dashboard/backend/templates/browsers.html
toto fde6864311 feat(dashboard): browser signatures management UI
- Ajoute dict_browser_h2 dans /reflists (lecture seule via dict_browser_h2)
- Nouveaux endpoints API :
    GET  /api/browser-signatures/entries — liste browser_h2_signatures
         (fallback dict CSV si migration 06 non appliquée)
    POST /api/browser-signatures/entries — ajout fingerprint + reload dict
    DELETE /api/browser-signatures/entries — suppression + reload dict
- Page /browsers : 2 nouvelles sections
    'Base de signatures H2' — tableau des 10 fingerprints, form d'ajout,
    mode lecture seule automatique si migration 06 non appliquée
    'Règles de scoring browser_matcher.py' — tableau statique des 7 dimensions
    (poids, valeurs par famille, seuils de bypass)
- Integration : browser_h2.csv copié dans user_files au démarrage ClickHouse

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

643 lines
37 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.

{% extends "base.html" %}
{% block title %}JA4 SOC — Browser Signatures{% endblock %}
{% block page_title %}
Browser Signature Detection
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Détection passive des navigateurs (§4)</h4>
<p>Analyse croisée du fingerprint HTTP/2 (SETTINGS, WINDOW_UPDATE, pseudo-headers)
et du JA4 TLS pour identifier les vrais navigateurs et détecter les imposteurs.</p>
<p><strong>TLS↔H2 mismatch :</strong> sessions où le JA4 identifie une famille
(ex: Chromium) mais les SETTINGS H2 appartiennent à une autre (ex: Firefox).
Signal fort d'un outil qui émule un TLS de navigateur sans répliquer le H2.</p>
<p class="doc-source">Source : view_ai_features_1h (dict_browser_h2, h2_window_*)</p>
</div></span>
{% endblock %}
{% block content %}
<div class="p-4 lg:p-6 space-y-4 max-w-[1920px] mx-auto">
<!-- ═══ KPI Header ═══ -->
<div class="flex flex-wrap items-center gap-6 mb-2">
<div class="text-center px-3">
<div class="text-2xl font-bold text-white" id="kpi-total"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Sessions (24h)</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-indigo-400" id="kpi-h2"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Avec données H2</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-emerald-400" id="kpi-matched"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Famille H2 connue</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-amber-400" id="kpi-match-rate"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Taux match H2</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-red-400" id="kpi-mismatch"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Mismatch TLS↔H2</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-red-300" id="kpi-mismatch-rate"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">% Mismatch / H2</div>
</div>
</div>
<!-- ═══ Doc banner ═══ -->
<div class="bg-gray-900/50 border border-gray-800 rounded-lg px-4 py-3 text-xs text-gray-400 leading-relaxed">
<strong class="text-indigo-300">Browser Signature Detection §4</strong> — Le moteur
<code class="text-indigo-200">browser_matcher</code> score chaque session sur 7 dimensions :
<strong>H2 SETTINGS</strong> (0.30), <strong>WINDOW_UPDATE</strong> (0.15),
<strong>pseudo-headers</strong> (0.15), H2 PRIORITY (0.10), HTTP headers (0.15),
structure TLS (0.10), dictionnaire JA4 (0.05).
Un <strong class="text-amber-300">mismatch TLS↔H2</strong> est détecté quand le JA4 (couche TLS)
identifie Chrome mais le WINDOW_UPDATE H2 est celui de Firefox (ou vice-versa) — signature
d'un outil qui copie le TLS sans répliquer fidèlement le H2.
En mode <strong>DUAL_MODE</strong> (défaut), les décisions sont journalisées sans modifier le bypass —
activer <code class="text-green-300">BROWSER_MATCHER_REPLACE=true</code> pour basculer.
</div>
<!-- ═══ Row 1 : Familles H2 + WINDOW_UPDATE ═══ -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Donut : distribution des familles H2 -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Familles détectées — dict H2
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Familles H2 (dict_browser_h2)</h4>
<p>Correspondance du fingerprint SETTINGS H2 complet (format Akamai)
avec le dictionnaire <code>dict_browser_h2</code>. Une correspondance
exacte identifie Chrome, Firefox, Safari ou Edge avec un haut degré
de certitude.</p>
<p class="doc-source">Source : view_ai_features_1h.h2_dict_family</p>
</div></span>
</span>
</div>
<div class="section-body">
<div id="chart-families" style="height:320px"></div>
</div>
</div>
<!-- Bar : WINDOW_UPDATE signals -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Signal WINDOW_UPDATE H2
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Valeur WINDOW_UPDATE par famille</h4>
<p>La valeur du frame WINDOW_UPDATE est l'empreinte H2 la plus fiable car
chaque navigateur utilise une valeur distincte et constante :</p>
<ul class="list-disc ml-4 mt-1">
<li>Chrome : 15 663 105</li>
<li>Firefox : 12 517 377</li>
<li>Safari : 10 485 760</li>
<li>curl/httpx : absent (0)</li>
</ul>
<p class="doc-source">Source : view_ai_features_1h.h2_window_*</p>
</div></span>
</span>
</div>
<div class="section-body">
<div id="chart-wu-signals" style="height:320px"></div>
</div>
</div>
</div>
<!-- ═══ Row 2 : Mismatch par famille + table suspects ═══ -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Bar : mismatch par famille JA4 -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Mismatch TLS↔H2 par famille JA4
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Incohérences cross-layer</h4>
<p>Proportion de sessions dont la famille JA4 (TLS) contredit la famille H2.
Un taux élevé pour "Chromium" indique des outils qui imitent Chrome TLS
sans reproduire le comportement H2.</p>
<p><strong>Exemples de mismatch :</strong></p>
<ul class="list-disc ml-4 mt-1">
<li>JA4=Chromium + WU=12517377 → Firefox H2</li>
<li>JA4=Firefox + WU=15663105 → Chrome H2</li>
<li>JA4=Navigateur + WU absent → outil sans H2</li>
</ul>
<p class="doc-source">Source : view_ai_features_1h.tls_h2_family_mismatch</p>
</div></span>
</span>
</div>
<div class="section-body">
<div id="chart-mismatch" style="height:320px"></div>
</div>
</div>
<!-- Sessions suspectes avec mismatch -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Sessions suspectes (mismatch confirmé)
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Imposteurs potentiels</h4>
<p>Sessions dont le JA4 (TLS) et les SETTINGS H2 identifient des familles
différentes — signal fort d'un outil qui émule le TLS d'un navigateur
mais trahit son origine via le H2.</p>
<p><strong>Action :</strong> Investiguer l'IP, vérifier le JA4 dans la page
Détections, ou ajouter à la liste de blocage.</p>
</div></span>
</span>
</div>
<div class="section-body p-0 overflow-x-auto">
<table class="w-full text-xs text-left" id="tbl-suspects">
<thead class="text-gray-500 border-b border-gray-800">
<tr>
<th class="px-3 py-2 font-medium">IP</th>
<th class="px-3 py-2 font-medium">JA4 famille</th>
<th class="px-3 py-2 font-medium">H2 famille</th>
<th class="px-3 py-2 font-medium">WU value</th>
<th class="px-3 py-2 font-medium">Pseudo-order</th>
<th class="px-3 py-2 font-medium text-right">Hits</th>
<th class="px-3 py-2 font-medium text-right">Cohérence</th>
</tr>
</thead>
<tbody id="tbl-suspects-body" class="divide-y divide-gray-800/50">
<tr><td colspan="7" class="px-3 py-6 text-center text-gray-600">Chargement…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ Row 3 : Pseudo-header orders ═══ -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Distribution des ordres pseudo-headers H2
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Ordre des pseudo-headers HTTP/2</h4>
<p>L'ordre des pseudo-headers (<code>:method</code>, <code>:authority</code>,
<code>:scheme</code>, <code>:path</code>) est spécifique à chaque navigateur :</p>
<ul class="list-disc ml-4 mt-1">
<li><strong>m,a,s,p</strong> — Chrome et Safari</li>
<li><strong>m,p,s,a</strong> — Firefox</li>
</ul>
<p>Un ordre non répertorié indique un outil ou une version rare.</p>
<p class="doc-source">Source : view_ai_features_1h (h2_order_chromesafari, h2_order_firefox)</p>
</div></span>
</span>
</div>
<div class="section-body">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-gray-900 rounded-lg px-4 py-3 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Chrome / Safari — <code>m,a,s,p</code></div>
<div class="text-2xl font-bold text-indigo-400" id="pseudo-chromesafari"></div>
<div class="text-[10px] text-gray-600 mt-1">sessions</div>
</div>
<div class="bg-gray-900 rounded-lg px-4 py-3 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Firefox — <code>m,p,s,a</code></div>
<div class="text-2xl font-bold text-orange-400" id="pseudo-firefox"></div>
<div class="text-[10px] text-gray-600 mt-1">sessions</div>
</div>
<div class="bg-gray-900 rounded-lg px-4 py-3 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">H2 présent — ordre non répertorié</div>
<div class="text-2xl font-bold text-gray-400" id="pseudo-other"></div>
<div class="text-[10px] text-gray-600 mt-1">sessions (outil probable)</div>
</div>
<div class="bg-gray-900 rounded-lg px-4 py-3 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">H2 PRIORITY frames présents</div>
<div class="text-2xl font-bold text-yellow-400" id="pseudo-priority"></div>
<div class="text-[10px] text-gray-600 mt-1">sessions (Firefox ancien)</div>
</div>
</div>
</div>
</div>
<!-- ═══ Row 4 : Base de signatures H2 ═══ -->
<div class="section-card" id="section-sig-h2">
<div class="section-header flex items-center justify-between">
<span class="section-title">
Base de signatures H2
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Table <code>browser_h2_signatures</code></h4>
<p>Source des fingerprints HTTP/2 (format Akamai) utilisés par
<code>dict_browser_h2</code>. Le dictionnaire est rechargé automatiquement
après chaque ajout ou suppression.</p>
<p>Format : <code>SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_ORDER</code><br>
Exemple Chrome : <code>1:65536,2:0,4:6291456,6:262144|15663105|0|m,a,s,p</code></p>
<p class="doc-source">Source : ja4_processing.browser_h2_signatures (migration 06)</p>
</div></span>
</span>
<button onclick="toggleAddForm()" class="text-xs bg-indigo-700 hover:bg-indigo-600 text-white px-3 py-1 rounded font-medium transition-colors">+ Ajouter</button>
</div>
<!-- Formulaire d'ajout (masqué par défaut) -->
<div id="form-add-sig" class="hidden px-4 py-3 bg-gray-950 border-b border-gray-800">
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 text-xs">
<div class="md:col-span-2">
<label class="text-gray-400 block mb-1">H2 Fingerprint (format Akamai)</label>
<input id="inp-fp" type="text" placeholder="1:65536,2:0,4:6291456,6:262144|15663105|0|m,a,s,p"
class="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-gray-200 font-mono text-[11px] focus:outline-none focus:border-indigo-500">
</div>
<div>
<label class="text-gray-400 block mb-1">Famille</label>
<select id="inp-family" class="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500">
<option value="Chrome">Chrome</option>
<option value="Firefox">Firefox</option>
<option value="Safari">Safari</option>
<option value="Edge">Edge</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<label class="text-gray-400 block mb-1">Confidence (01)</label>
<input id="inp-conf" type="number" step="0.05" min="0" max="1" value="1.0"
class="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500">
</div>
<div class="md:col-span-3">
<label class="text-gray-400 block mb-1">Notes</label>
<input id="inp-notes" type="text" placeholder="ex: Chrome 143 beta"
class="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500">
</div>
<div class="flex items-end">
<button onclick="submitAdd()" class="w-full bg-emerald-700 hover:bg-emerald-600 text-white rounded px-3 py-1.5 text-xs font-medium transition-colors">Enregistrer</button>
</div>
</div>
<div id="add-status" class="mt-2 text-xs hidden"></div>
</div>
<div class="section-body overflow-x-auto">
<table class="w-full text-xs text-gray-400">
<thead class="border-b border-gray-800 text-left">
<tr>
<th class="px-3 py-2 font-medium">Fingerprint H2 (format Akamai)</th>
<th class="px-3 py-2 font-medium w-24">Famille</th>
<th class="px-3 py-2 font-medium w-24 text-center">Confidence</th>
<th class="px-3 py-2 font-medium">Notes</th>
<th class="px-3 py-2 font-medium w-16 text-center">Action</th>
</tr>
</thead>
<tbody id="tbl-sigs-body" class="divide-y divide-gray-800/50">
<tr><td colspan="5" class="px-3 py-6 text-center text-gray-600">Chargement…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ═══ Row 5 : Règles de scoring Python (browser_matcher) ═══ -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Règles de scoring — <code>browser_matcher.py</code>
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Dimensions du scoring navigateur</h4>
<p>Le browser_matcher calcule un score 01 par famille en agrégeant 7 dimensions.
Ces règles sont définies dans <code>bot_detector/browser_signatures.py</code>.</p>
<p class="doc-source">Modification : éditer le fichier Python et redéployer bot-detector</p>
</div></span>
</span>
</div>
<div class="section-body">
<div class="overflow-x-auto">
<table class="w-full text-xs text-gray-400">
<thead class="border-b border-gray-800 text-left">
<tr>
<th class="px-3 py-2 font-medium">Dimension</th>
<th class="px-3 py-2 font-medium text-right w-16">Poids</th>
<th class="px-3 py-2 font-medium">Chrome</th>
<th class="px-3 py-2 font-medium">Firefox</th>
<th class="px-3 py-2 font-medium">Safari</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50 font-mono text-[11px]">
<tr>
<td class="px-3 py-2 text-gray-300 font-sans text-xs">H2 SETTINGS exact</td>
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.30</td>
<td class="px-3 py-2">1:65536,2:0,4:6291456,6:262144</td>
<td class="px-3 py-2">1:65536,4:131072,5:16384</td>
<td class="px-3 py-2">1:4096,3:100,4:65535</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-300 font-sans text-xs">H2 WINDOW_UPDATE</td>
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.15</td>
<td class="px-3 py-2 text-indigo-400">15 663 105</td>
<td class="px-3 py-2 text-orange-400">12 517 377</td>
<td class="px-3 py-2 text-cyan-400">10 485 760</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-300 font-sans text-xs">Pseudo-header order</td>
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.15</td>
<td class="px-3 py-2">m,a,s,p</td>
<td class="px-3 py-2">m,p,s,a</td>
<td class="px-3 py-2">m,a,s,p</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-300 font-sans text-xs">HTTP headers cohérence</td>
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.15</td>
<td class="px-3 py-2 text-[10px]">Sec-CH-UA ✓ · Sec-Fetch ✓</td>
<td class="px-3 py-2 text-[10px]">Sec-CH-UA ✗ · Sec-Fetch ✓</td>
<td class="px-3 py-2 text-[10px]">Sec-CH-UA ✗ · Sec-Fetch ✗</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-300 font-sans text-xs">H2 PRIORITY frames</td>
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.10</td>
<td class="px-3 py-2 text-gray-500">absent</td>
<td class="px-3 py-2 text-gray-500">absent</td>
<td class="px-3 py-2 text-gray-500">absent</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-300 font-sans text-xs">TLS structure (JA4 famille)</td>
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.10</td>
<td class="px-3 py-2 text-[10px]">Chromium · Chrome · Edge + GREASE</td>
<td class="px-3 py-2 text-[10px]">Firefox · pas de GREASE</td>
<td class="px-3 py-2 text-[10px]">Safari · pas de GREASE</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-300 font-sans text-xs">JA4 dict lookup</td>
<td class="px-3 py-2 text-right text-amber-400 font-sans">0.05</td>
<td colspan="3" class="px-3 py-2 text-gray-500">dict_browser_ja4 — correspondance fingerprint TLS exact</td>
</tr>
<tr class="bg-gray-900/50">
<td class="px-3 py-2 text-gray-300 font-sans font-semibold">Seuil de bypass ML</td>
<td class="px-3 py-2 text-right text-emerald-400 font-sans font-semibold"></td>
<td class="px-3 py-2 text-emerald-400 font-sans">≥ 0.72</td>
<td class="px-3 py-2 text-emerald-400 font-sans">≥ 0.68</td>
<td class="px-3 py-2 text-emerald-400 font-sans">≥ 0.68</td>
</tr>
</tbody>
</table>
</div>
<p class="mt-3 text-[10px] text-gray-600">Mode actuel : <strong class="text-gray-400">DUAL_MODE</strong> — le matcher journalise les décisions sans modifier le scoring ML.
Activer le bypass : variable d'environnement <code>BROWSER_MATCHER_REPLACE=true</code> dans bot-detector.</p>
</div>
</div>
</div>
<script>
// ─── Gestion des signatures H2 ───────────────────────────────────────────
const FAM_COLORS = {
Chrome: 'text-indigo-400', Firefox: 'text-orange-400',
Safari: 'text-cyan-400', Edge: 'text-purple-400', Other: 'text-gray-400',
};
function toggleAddForm() {
const el = document.getElementById('form-add-sig');
el.classList.toggle('hidden');
}
async function loadSignatureEntries() {
try {
const r = await fetch('/api/browser-signatures/entries');
if (!r.ok) throw new Error(r.statusText);
const data = await r.json();
const isReadonly = data.readonly === true;
// Masquer le bouton Ajouter si source = dict CSV (table pas encore créée)
if (isReadonly) {
document.querySelector('#section-sig-h2 .section-header button')?.setAttribute('disabled', 'true');
document.querySelector('#section-sig-h2 .section-header button')?.classList.add('opacity-40', 'cursor-not-allowed');
}
renderSigTable(data.entries || [], isReadonly);
} catch (e) {
document.getElementById('tbl-sigs-body').innerHTML =
`<tr><td colspan="5" class="px-3 py-4 text-center text-gray-600">Indisponible</td></tr>`;
}
}
function renderSigTable(rows, readonly = false) {
const tbody = document.getElementById('tbl-sigs-body');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" class="px-3 py-6 text-center text-gray-600">Aucun fingerprint enregistré</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const fc = FAM_COLORS[r.browser_family] || 'text-gray-400';
const conf = typeof r.confidence === 'number' ? r.confidence.toFixed(2) : (r.confidence || '—');
const confColor = (r.confidence || 0) >= 0.95 ? 'text-emerald-400' : (r.confidence || 0) >= 0.8 ? 'text-amber-400' : 'text-red-400';
const fpEsc = encodeURIComponent(r.h2_fingerprint);
const actionCell = readonly
? '<td class="px-3 py-2 text-center text-gray-700 text-[10px]">lecture seule</td>'
: `<td class="px-3 py-2 text-center">
<button onclick="deleteSig('${fpEsc}')" class="text-red-500 hover:text-red-400 text-[11px] transition-colors" title="Supprimer">✕</button>
</td>`;
return `<tr class="hover:bg-gray-800/20 transition-colors">
<td class="px-3 py-2 font-mono text-[11px] text-gray-300 break-all">${escHtml(r.h2_fingerprint)}</td>
<td class="px-3 py-2 font-semibold ${fc}">${r.browser_family}</td>
<td class="px-3 py-2 text-center font-semibold ${confColor}">${conf}</td>
<td class="px-3 py-2 text-gray-500">${escHtml(r.notes || '')}</td>
${actionCell}
</tr>`;
}).join('');
}
async function submitAdd() {
const fp = document.getElementById('inp-fp').value.trim();
const fam = document.getElementById('inp-family').value;
const conf = parseFloat(document.getElementById('inp-conf').value);
const notes = document.getElementById('inp-notes').value.trim();
const status = document.getElementById('add-status');
if (!fp) { showStatus(status, 'Le fingerprint est requis', 'error'); return; }
try {
const r = await fetch('/api/browser-signatures/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ h2_fingerprint: fp, browser_family: fam, confidence: conf, notes }),
});
const data = await r.json();
if (!r.ok) { showStatus(status, data.detail || 'Erreur', 'error'); return; }
showStatus(status, '✓ Enregistré — dictionnaire rechargé', 'ok');
document.getElementById('inp-fp').value = '';
document.getElementById('inp-notes').value = '';
setTimeout(() => document.getElementById('form-add-sig').classList.add('hidden'), 1500);
await loadSignatureEntries();
} catch (e) {
showStatus(status, e.message, 'error');
}
}
async function deleteSig(fpEncoded) {
const fp = decodeURIComponent(fpEncoded);
if (!confirm(`Supprimer ce fingerprint ?\n\n${fp}`)) return;
try {
const r = await fetch(`/api/browser-signatures/entries?fingerprint=${fpEncoded}`, { method: 'DELETE' });
if (!r.ok) { alert('Erreur lors de la suppression'); return; }
await loadSignatureEntries();
} catch (e) { alert(e.message); }
}
function showStatus(el, msg, type) {
el.textContent = msg;
el.className = 'mt-2 text-xs ' + (type === 'ok' ? 'text-emerald-400' : 'text-red-400');
el.classList.remove('hidden');
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ─── Chargement des données ───────────────────────────────────────────────
async function loadBrowserData() {
let data;
try {
const r = await fetch('/api/browser-signatures');
if (!r.ok) throw new Error(r.statusText);
data = await r.json();
} catch (e) {
console.error('browsers API error', e);
return;
}
// KPIs
const s = data.stats || {};
setText('kpi-total', fmt(s.total_sessions));
setText('kpi-h2', fmt(s.sessions_with_h2));
setText('kpi-matched', fmt(s.sessions_matched));
setText('kpi-match-rate', (s.match_rate ?? '—') + (s.match_rate != null ? '%' : ''));
setText('kpi-mismatch', fmt(s.sessions_mismatch));
setText('kpi-mismatch-rate', (s.mismatch_rate ?? '—') + (s.mismatch_rate != null ? '%' : ''));
// Donut — familles H2
renderFamiliesDonut(data.h2_families || []);
// Bar — WINDOW_UPDATE signals
renderWuBar(data.h2_window_signals || []);
// Bar — mismatch par famille
renderMismatchBar(data.mismatch_by_family || []);
// Table suspects
renderSuspects(data.top_mismatches || []);
// Pseudo-header counters (déduire depuis mismatch_by_family stats + wu_signals)
// On recalcule depuis les familles et les sessions_with_h2
const totalH2 = s.sessions_with_h2 || 0;
const wuData = data.h2_window_signals || [];
const chrome = (wuData.find(r => r.signal.startsWith('Chrome')) || {}).sessions || 0;
const firefox = (wuData.find(r => r.signal.startsWith('Firefox')) || {}).sessions || 0;
const safari = (wuData.find(r => r.signal.startsWith('Safari')) || {}).sessions || 0;
const absent = (wuData.find(r => r.signal.startsWith('Absent')) || {}).sessions || 0;
const other = (wuData.find(r => r.signal.startsWith('Autre')) || {}).sessions || 0;
// h2_order_chromesafari ≈ chrome + safari (partagent m,a,s,p)
setText('pseudo-chromesafari', fmt(chrome + safari));
setText('pseudo-firefox', fmt(firefox));
setText('pseudo-other', fmt(other));
// Pour h2_priority_present on n'a pas le chiffre direct — afficher N/A
setText('pseudo-priority', '—');
}
// ─── Graphiques ──────────────────────────────────────────────────────────
function renderFamiliesDonut(rows) {
const chart = echarts.init(document.getElementById('chart-families'), 'dark');
if (!rows.length) { chart.setOption({ title: { text: 'Aucune donnée H2', left: 'center', top: 'center', textStyle: { color: '#6b7280', fontSize: 13 } } }); return; }
const COLORS = { Chrome: '#6366f1', Firefox: '#f97316', Safari: '#22d3ee', Edge: '#8b5cf6', '': '#374151' };
const data = rows.map(r => ({
name: r.family || 'Inconnu',
value: r.sessions,
itemStyle: { color: COLORS[r.family] || '#64748b' }
}));
chart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c} sessions ({d}%)' },
legend: { orient: 'vertical', right: 10, top: 'center', textStyle: { color: '#9ca3af', fontSize: 11 } },
series: [{
type: 'pie', radius: ['45%', '72%'], center: ['40%', '50%'],
avoidLabelOverlap: true,
label: { show: false },
emphasis: { label: { show: true, fontSize: 13, fontWeight: 'bold' } },
data
}]
});
window.addEventListener('resize', () => chart.resize());
}
function renderWuBar(rows) {
const chart = echarts.init(document.getElementById('chart-wu-signals'), 'dark');
if (!rows.length) { chart.setOption({ title: { text: 'Aucune donnée H2', left: 'center', top: 'center', textStyle: { color: '#6b7280', fontSize: 13 } } }); return; }
const COLORS = ['#6366f1', '#f97316', '#22d3ee', '#ef4444', '#6b7280'];
chart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '2%', right: '4%', bottom: '3%', top: '4%', containLabel: true },
xAxis: { type: 'value', axisLine: { lineStyle: { color: '#374151' } }, axisLabel: { color: '#6b7280', fontSize: 10 } },
yAxis: { type: 'category', data: rows.map(r => r.signal), axisLabel: { color: '#9ca3af', fontSize: 10 }, axisLine: { lineStyle: { color: '#374151' } } },
series: [{
type: 'bar', barMaxWidth: 32,
data: rows.map((r, i) => ({ value: r.sessions, itemStyle: { color: COLORS[i % COLORS.length] } })),
label: { show: true, position: 'right', color: '#9ca3af', fontSize: 10, formatter: v => fmt(v.value) }
}]
});
window.addEventListener('resize', () => chart.resize());
}
function renderMismatchBar(rows) {
const chart = echarts.init(document.getElementById('chart-mismatch'), 'dark');
if (!rows.length) { chart.setOption({ title: { text: 'Aucune donnée de mismatch', left: 'center', top: 'center', textStyle: { color: '#6b7280', fontSize: 13 } } }); return; }
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: params => {
const r = rows[params[0].dataIndex];
return `${r.ja4_family}<br/>Mismatch : ${fmt(r.mismatches)} / ${fmt(r.total)} (${r.mismatch_pct}%)`;
}
},
grid: { left: '2%', right: '4%', bottom: '3%', top: '4%', containLabel: true },
xAxis: { type: 'category', data: rows.map(r => r.ja4_family), axisLabel: { color: '#9ca3af', fontSize: 10 }, axisLine: { lineStyle: { color: '#374151' } } },
yAxis: [
{ type: 'value', name: 'Sessions', nameTextStyle: { color: '#6b7280', fontSize: 10 }, axisLabel: { color: '#6b7280', fontSize: 10 }, axisLine: { lineStyle: { color: '#374151' } } },
{ type: 'value', name: '% mismatch', max: 100, nameTextStyle: { color: '#ef4444', fontSize: 10 }, axisLabel: { color: '#ef4444', fontSize: 10, formatter: '{value}%' }, axisLine: { lineStyle: { color: '#374151' } } }
],
series: [
{ name: 'Total', type: 'bar', barMaxWidth: 40, itemStyle: { color: '#374151' }, data: rows.map(r => r.total) },
{ name: 'Mismatch', type: 'bar', barMaxWidth: 40, itemStyle: { color: '#ef4444' }, data: rows.map(r => r.mismatches) },
{ name: '% mismatch', type: 'line', yAxisIndex: 1, symbol: 'circle', symbolSize: 6, lineStyle: { color: '#fbbf24' }, itemStyle: { color: '#fbbf24' }, data: rows.map(r => r.mismatch_pct) }
],
legend: { textStyle: { color: '#9ca3af', fontSize: 10 }, top: 0 }
});
window.addEventListener('resize', () => chart.resize());
}
function renderSuspects(rows) {
const tbody = document.getElementById('tbl-suspects-body');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="7" class="px-3 py-6 text-center text-gray-600">Aucun mismatch détecté dans les 24 dernières heures</td></tr>';
return;
}
const WU_LABELS = {
15663105: '<span class="text-indigo-400">Chrome</span>',
12517377: '<span class="text-orange-400">Firefox</span>',
10485760: '<span class="text-cyan-400">Safari</span>',
1073676289: '<span class="text-gray-400">go net/http</span>',
0: '<span class="text-red-400">absent</span>',
};
tbody.innerHTML = rows.map(r => {
const wuLabel = WU_LABELS[r.wu_value] || `<span class="text-gray-400">${r.wu_value}</span>`;
const coh = r.coherence != null ? r.coherence.toFixed(2) : '—';
const cohColor = r.coherence < 0.4 ? 'text-red-400' : r.coherence < 0.6 ? 'text-amber-400' : 'text-emerald-400';
const h2fam = r.h2_dict_family || '<span class="text-gray-600">inconnu</span>';
return `<tr class="hover:bg-gray-800/30 transition-colors">
<td class="px-3 py-2 font-mono text-indigo-300"><a href="/ip/${r.ip}" class="hover:underline">${r.ip}</a></td>
<td class="px-3 py-2 text-gray-300">${r.ja4_family || '—'}</td>
<td class="px-3 py-2 text-amber-300">${h2fam}</td>
<td class="px-3 py-2">${wuLabel}</td>
<td class="px-3 py-2 font-mono text-gray-400">${r.pseudo_order || '—'}</td>
<td class="px-3 py-2 text-right text-gray-300">${fmt(r.hits)}</td>
<td class="px-3 py-2 text-right font-semibold ${cohColor}">${coh}</td>
</tr>`;
}).join('');
}
// ─── Utils ────────────────────────────────────────────────────────────────
function setText(id, val) { const el = document.getElementById(id); if (el) el.textContent = val ?? '—'; }
function fmt(n) { return n == null ? '—' : Number(n).toLocaleString('fr-FR'); }
loadBrowserData();
loadSignatureEntries();
</script>
{% endblock %}