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>
This commit is contained in:
@ -159,6 +159,10 @@
|
||||
<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">
|
||||
|
||||
381
services/dashboard/backend/templates/browsers.html
Normal file
381
services/dashboard/backend/templates/browsers.html
Normal file
@ -0,0 +1,381 @@
|
||||
{% 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">
|
||||
<!-- mini KPIs pour les ordres pseudo-headers -->
|
||||
<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>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── Chargement des données ───────────────────────────────────────────────
|
||||
async function loadBrowserData() {
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch('/api/browsers');
|
||||
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();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user