Files
toto 261205028d fix(dashboard): campaigns scatter chart — show campaigns not IPs
- API /api/campaigns/scatter: aggregate by campaign_id instead of per-IP
  Returns avg_score, avg_velocity, unique_ips, ja4_list, asn_list, country_list
- Template: one bubble per campaign, sized by IP count
- Tooltip: campaign-level info (IPs, score, velocity, ASNs, pays, JA4s)
- Click navigates to campaign detail (not IP detail)
- Updated doc panel text

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

754 lines
40 KiB
HTML
Raw Permalink 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 page_title %}Campagnes — Clusters HDBSCAN{% endblock %}
{% block content %}
<style>
/* ── Force-graph canvas ── */
#graph-canvas { width:100%; height:100%; }
.graph-tooltip {
position:absolute; background:rgba(17,24,39,0.95); border:1px solid #374151;
border-radius:8px; padding:8px 12px; font-size:11px; color:#e5e7eb;
pointer-events:none; z-index:100; max-width:260px; line-height:1.5;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
/* ── Campaign cards ── */
.camp-card {
background:#111827; border:1px solid #1f2937; border-radius:10px;
padding:16px; cursor:pointer; transition:all 0.2s;
}
.camp-card:hover { border-color:#6366f1; background:#1e1b4b22; }
.camp-card.active { border-color:#818cf8; background:#1e1b4b33; box-shadow:0 0 0 1px #818cf8; }
.camp-chip {
display:inline-flex; align-items:center; gap:4px;
padding:2px 8px; border-radius:9999px; font-size:10px; font-weight:500;
}
/* ── Detail panel ── */
.detail-panel { display:none; }
.detail-panel.open { display:block; }
/* ── Radar chart container ── */
.radar-wrap { position:relative; width:100%; max-width:320px; margin:0 auto; }
/* ── Scatter bubbles ── */
.scatter-legend { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
.scatter-legend-item { display:flex; align-items:center; gap:4px; font-size:10px; color:#9ca3af; }
.scatter-dot { width:10px; height:10px; border-radius:50%; }
</style>
<div class="p-4 lg:p-6 space-y-4 max-w-[1920px] mx-auto">
<!-- ═══ Header KPIs ═══ -->
<div class="flex flex-wrap items-center gap-4 mb-2">
<h1 class="text-xl font-bold text-white flex items-center gap-2">
<svg class="w-6 h-6 text-purple-400" 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>
Campagnes de bots
</h1>
<div class="ml-auto flex items-center gap-3">
<!-- ── Sélecteur de plage temporelle ── -->
<div class="flex items-center gap-1 bg-gray-800/60 rounded-lg p-1" id="days-selector">
<button onclick="setDays(1)" data-days="1" class="days-btn px-2 py-1 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors">1j</button>
<button onclick="setDays(7)" data-days="7" class="days-btn px-2 py-1 rounded text-xs text-white bg-purple-600">7j</button>
<button onclick="setDays(14)" data-days="14" class="days-btn px-2 py-1 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors">14j</button>
<button onclick="setDays(30)" data-days="30" class="days-btn px-2 py-1 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors">30j</button>
<button onclick="setDays(90)" data-days="90" class="days-btn px-2 py-1 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors">90j</button>
</div>
<div class="text-center px-3">
<div class="text-2xl font-bold text-purple-400" id="kpi-total"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Campagnes</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-red-400" id="kpi-ips"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">IPs impliquées</div>
</div>
<div class="text-center px-3 border-l border-gray-700">
<div class="text-2xl font-bold text-amber-400" id="kpi-detections"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Détections</div>
</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-purple-300">Clustering HDBSCAN</strong> — Le bot-detector regroupe les anomalies
par similarité comportementale dans l'espace latent de l'autoencoder (16 dimensions).
Chaque <strong>campagne</strong> représente un ensemble d'IPs partageant des patterns
d'attaque similaires (même JA4, même cadence, mêmes cibles). Les IPs isolées (campaign_id = 1)
ne sont pas affichées ici.
<br><strong>Action SOC :</strong> Une campagne multi-IP indique une attaque coordonnée
(botnet, scraping distribué, credential stuffing). Cliquez sur une campagne pour investiguer.
</div>
<!-- ═══ Top row: Scatter + Network graph ═══ -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<!-- Scatter plot: Score vs Velocity -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="7" cy="18" r="2"/><circle cx="18" cy="16" r="2.5"/></svg>
Carte des clusters
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Scatter — Score vs Vélocité</h4>
<p>Chaque bulle = une campagne. Position : score d'anomalie moyen (X) vs vitesse de requêtes moyenne (Y).
Taille = nombre d'IPs. Couleur = campagne.</p>
<p><strong>Lecture :</strong> Les campagnes proches partagent des comportements similaires.
Cliquer sur une bulle ouvre le détail de la campagne.</p>
<p class="doc-source">Source : ml_detected_anomalies WHERE campaign_id ≥ 0</p>
</div></span>
</span>
</div>
<div class="section-body" style="height:380px">
<canvas id="scatter-chart"></canvas>
<div class="scatter-legend" id="scatter-legend"></div>
</div>
</div>
<!-- Network graph -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
Graphe de liens
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Graphe réseau inter-campagnes</h4>
<p>Nœuds = IPs anomales, Arêtes = JA4 partagé au sein d'une campagne.
Couleur du nœud = campagne. Taille = nombre de hits.</p>
<p><strong>Lecture :</strong> Des IPs très connectées au centre du cluster
sont le « cœur » de la campagne. Les nœuds isolés en périphérie sont des IPs
moins caractéristiques.</p>
<p class="doc-source">Source : ml_detected_anomalies JOINs par JA4</p>
</div></span>
</span>
</div>
<div class="section-body relative" style="height:380px" id="graph-container">
<canvas id="graph-canvas"></canvas>
<div class="graph-tooltip" id="graph-tip" style="display:none"></div>
</div>
</div>
</div>
<!-- ═══ Campaign cards grid ═══ -->
<div class="section-card">
<div class="section-header">
<span class="section-title">
Campagnes détectées
<span class="text-xs text-gray-500 ml-2" id="camp-count"></span>
</span>
</div>
<div class="section-body p-4">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3" id="camp-grid">
<div class="text-center text-gray-500 text-sm py-8 col-span-full">Chargement des campagnes…</div>
</div>
</div>
</div>
<!-- ═══ Campaign detail panel (hidden until click) ═══ -->
<div class="detail-panel" id="detail-panel">
<div class="section-card border-purple-600/40">
<div class="section-header">
<span class="section-title">
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
Détail campagne <span class="font-mono text-purple-300" id="detail-cid"></span>
</span>
<button onclick="closeDetail()" class="text-gray-400 hover:text-white transition-colors text-sm">✕ Fermer</button>
<a id="detail-link" href="/cluster/0" class="px-3 py-1 bg-brand-600 text-white rounded text-xs font-medium hover:bg-brand-500 ml-2">Ouvrir ↗</a>
</div>
<div class="section-body p-0">
<!-- Detail KPIs -->
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-0 border-b border-gray-800" id="detail-kpis"></div>
<!-- Detail content: radar + timeline + members -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-0">
<!-- Radar chart -->
<div class="border-r border-gray-800 p-4">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Profil comportemental</h3>
<div class="radar-wrap"><canvas id="radar-chart"></canvas></div>
<p class="text-[10px] text-gray-600 mt-2 text-center">
Radar des features moyennes normalisées de la campagne.
Comparez avec le profil d'un trafic normal pour identifier les écarts.
</p>
</div>
<!-- Timeline -->
<div class="border-r border-gray-800 p-4">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Activité temporelle</h3>
<div style="height:220px"><canvas id="timeline-chart"></canvas></div>
<p class="text-[10px] text-gray-600 mt-2 text-center">
Détections et IPs actives par heure. Des pics synchronisés confirment une coordination.
</p>
</div>
<!-- Shared attributes -->
<div class="p-4">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Attributs partagés</h3>
<div id="detail-attrs" class="space-y-3 text-xs"></div>
</div>
</div>
<!-- Members table -->
<div class="border-t border-gray-800">
<div class="px-4 py-3 flex items-center justify-between border-b border-gray-800/50">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
IPs membres <span class="text-gray-500" id="member-count"></span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="text-[10px] text-gray-500 uppercase bg-gray-900/50">
<tr>
<th class="px-3 py-2 text-left">IP</th>
<th class="px-3 py-2 text-left">JA4</th>
<th class="px-3 py-2 text-left">Host</th>
<th class="px-3 py-2 text-right">Score</th>
<th class="px-3 py-2 text-left">Menace</th>
<th class="px-3 py-2 text-right">Hits</th>
<th class="px-3 py-2 text-right">Vélocité</th>
<th class="px-3 py-2 text-left">ASN</th>
<th class="px-3 py-2 text-left">Pays</th>
<th class="px-3 py-2 text-left">Date</th>
</tr>
</thead>
<tbody id="members-body" class="divide-y divide-gray-800/50"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Chart.js (already loaded by base, but ensure) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
/* ════════════════════════════════════════════════════════════════════════════
* Utilitaires
* ════════════════════════════════════════════════════════════════════════════ */
const COLORS = [
'#818cf8','#f472b6','#34d399','#fbbf24','#60a5fa','#f87171',
'#a78bfa','#2dd4bf','#fb923c','#e879f9','#4ade80','#38bdf8',
'#facc15','#c084fc','#22d3ee','#fb7185',
];
function campColor(cid) { return COLORS[Math.abs(cid) % COLORS.length]; }
function escapeHtml(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
function fmtIP(ip) { return String(ip||'').replace('::ffff:',''); }
function fmtScore(s) {
const v = parseFloat(s)||0;
const cls = v >= 0.7 ? 'text-red-400' : v >= 0.3 ? 'text-amber-400' : 'text-green-400';
return `<span class="${cls}">${v.toFixed(4)}</span>`;
}
function threatBadge(t) {
const m = {CRITICAL:'badge-critical',HIGH:'badge-high',MEDIUM:'badge-medium',
KNOWN_BOT:'badge-bot',LEGITIMATE_BROWSER:'badge-browser',NORMAL:'badge-normal',ANUBIS_DENY:'badge-deny'};
return `<span class="badge ${m[t]||'badge-normal'}">${escapeHtml(t||'—')}</span>`;
}
function fmtCountry(cc) {
if (!cc) return '—';
const flag = cc.length===2 ? String.fromCodePoint(...[...cc.toUpperCase()].map(c=>0x1F1E6-65+c.charCodeAt(0))) : '';
return `${flag} ${cc}`;
}
/* ════════════════════════════════════════════════════════════════════════════
* Data loading
* ════════════════════════════════════════════════════════════════════════════ */
let _campaigns = [], _scatterData = [], _graphData = {nodes:[],edges:[]};
let _scatterChart = null, _radarChart = null, _timelineChart = null;
let _currentDays = 7;
function setDays(d) {
_currentDays = d;
document.querySelectorAll('.days-btn').forEach(b => {
const active = parseInt(b.dataset.days) === d;
b.className = `days-btn px-2 py-1 rounded text-xs transition-colors ${active ? 'text-white bg-purple-600' : 'text-gray-400 hover:text-white hover:bg-gray-700'}`;
});
closeDetail();
loadAll();
}
async function loadAll() {
document.getElementById('camp-count').textContent = '(chargement…)';
try {
const [campResp, scatterResp, graphResp] = await Promise.all([
fetch(`/api/campaigns?days=${_currentDays}`).then(r=>r.json()),
fetch(`/api/campaigns/scatter?days=${_currentDays}`).then(r=>r.json()),
fetch(`/api/campaigns/graph?days=${_currentDays}`).then(r=>r.json()),
]);
_campaigns = campResp.campaigns || [];
_scatterData = scatterResp.data || [];
_graphData = graphResp;
// KPIs
const totalIPs = _campaigns.reduce((s,c) => s + (c.unique_ips||0), 0);
const totalDet = _campaigns.reduce((s,c) => s + (c.members||0), 0);
document.getElementById('kpi-total').textContent = _campaigns.length;
document.getElementById('kpi-ips').textContent = totalIPs.toLocaleString();
document.getElementById('kpi-detections').textContent = totalDet.toLocaleString();
const label = _currentDays === 1 ? '24h' : `${_currentDays}j`;
document.getElementById('camp-count').textContent = `(${_campaigns.length} campagne${_campaigns.length!==1?'s':''}${label})`;
renderCampGrid();
renderScatter();
renderGraph();
} catch(e) {
console.error('Campaign load error:', e);
document.getElementById('camp-count').textContent = '(erreur)';
}
}
/* ════════════════════════════════════════════════════════════════════════════
* Campaign cards grid
* ════════════════════════════════════════════════════════════════════════════ */
function renderCampGrid() {
const el = document.getElementById('camp-grid');
if (!_campaigns.length) {
el.innerHTML = '<div class="text-center text-gray-500 text-sm py-8 col-span-full">Aucune campagne active détectée (7 derniers jours)</div>';
return;
}
el.innerHTML = _campaigns.map(c => {
const color = campColor(c.campaign_id);
const countries = (c.countries||[]).map(fmtCountry).join(' ');
const asns = (c.asn_list||[]).map(a => escapeHtml(a)).join(', ') || '—';
const ja4s = (c.ja4_list||[]).slice(0,3).map(j => `<span class="font-mono text-[10px] text-gray-400">${escapeHtml(j).substring(0,20)}…</span>`).join(' ');
return `<div class="camp-card" data-cid="${c.campaign_id}" onclick="selectCampaign(${c.campaign_id})">
<div class="flex items-center gap-2 mb-2">
<div class="w-3 h-3 rounded-full shrink-0" style="background:${color}"></div>
<span class="font-semibold text-sm text-white">Campagne #${c.campaign_id}</span>
<span class="ml-auto camp-chip bg-purple-500/20 text-purple-300">${c.members} dét.</span>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-gray-400 mb-2">
<div>IPs uniques : <span class="text-white">${c.unique_ips}</span></div>
<div>Score max : <span class="text-red-400">${(c.max_score||0).toFixed(3)}</span></div>
<div>Vélocité moy. : <span class="text-amber-300">${(c.avg_velocity||0).toFixed(1)} r/s</span></div>
<div>Fuzzing moy. : <span class="text-amber-300">${(c.avg_fuzzing||0).toFixed(2)}</span></div>
</div>
<div class="text-[10px] text-gray-500 mb-1">${countries}</div>
<div class="text-[10px] text-gray-500 truncate">ASN: ${asns}</div>
<div class="mt-1">${ja4s}</div>
</div>`;
}).join('');
}
/* ════════════════════════════════════════════════════════════════════════════
* Scatter chart (Chart.js)
* ════════════════════════════════════════════════════════════════════════════ */
function renderScatter() {
const canvas = document.getElementById('scatter-chart');
if (_scatterChart) _scatterChart.destroy();
// One bubble per campaign (data is already campaign-level aggregates)
const datasets = _scatterData.map(d => {
const cid = d.campaign_id;
const color = campColor(parseInt(cid));
return {
label: `Camp. #${cid}`,
data: [{
x: parseFloat(d.avg_score)||0,
y: parseFloat(d.avg_velocity)||0,
r: Math.max(6, Math.min(30, Math.sqrt(parseInt(d.unique_ips)||1)*3)),
_meta: d,
}],
backgroundColor: color + '88',
borderColor: color,
borderWidth: 1.5,
};
});
_scatterChart = new Chart(canvas, {
type: 'bubble',
data: { datasets },
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: {
title: { display:true, text:'Score d\'anomalie moyen', color:'#9ca3af', font:{size:11} },
grid: { color:'#1f293766' }, ticks: { color:'#6b7280', font:{size:10} },
},
y: {
title: { display:true, text:'Vélocité moy. (req/s)', color:'#9ca3af', font:{size:11} },
grid: { color:'#1f293766' }, ticks: { color:'#6b7280', font:{size:10} },
},
},
plugins: {
legend: { display:true, position:'bottom', labels:{color:'#9ca3af',boxWidth:10,font:{size:10}} },
tooltip: {
callbacks: {
title: ctx => {
const m = ctx[0]?.raw?._meta;
return m ? `Campagne #${m.campaign_id}` : '';
},
label: ctx => {
const m = ctx.raw._meta;
const lines = [
`IPs : ${m.unique_ips || '—'}`,
`Score moy. : ${(parseFloat(m.avg_score)||0).toFixed(4)}`,
`Vélocité moy. : ${(parseFloat(m.avg_velocity)||0).toFixed(1)} r/s`,
`Hits totaux : ${m.total_hits || '—'}`,
`Menace : ${m.threat || '—'}`,
];
if (m.asn_list?.length) lines.push(`ASN : ${m.asn_list.join(', ')}`);
if (m.country_list?.length) lines.push(`Pays : ${m.country_list.join(', ')}`);
if (m.ja4_list?.length) lines.push(`JA4 : ${m.ja4_list.map(j=>(j||'').substring(0,20)).join(', ')}`);
return lines;
}
}
},
},
onClick: (e, els) => {
if (els.length) {
const m = els[0].element.$context.raw._meta;
selectCampaign(m.campaign_id);
}
},
},
});
}
/* ════════════════════════════════════════════════════════════════════════════
* Force-directed graph (Canvas 2D, simple spring layout)
* ════════════════════════════════════════════════════════════════════════════ */
function renderGraph() {
const container = document.getElementById('graph-container');
const canvas = document.getElementById('graph-canvas');
const tip = document.getElementById('graph-tip');
const ctx = canvas.getContext('2d');
const W = container.clientWidth, H = container.clientHeight;
canvas.width = W * window.devicePixelRatio;
canvas.height = H * window.devicePixelRatio;
canvas.style.width = W+'px';
canvas.style.height = H+'px';
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
const nodes = (_graphData.nodes||[]).map((n,i) => ({
...n,
x: W/2 + (Math.random()-0.5)*W*0.6,
y: H/2 + (Math.random()-0.5)*H*0.6,
vx:0, vy:0,
radius: Math.max(4, Math.min(16, Math.sqrt(parseInt(n.total_hits)||1)*0.7)),
color: campColor(n.group),
}));
const nodeMap = {};
nodes.forEach(n => nodeMap[n.id] = n);
const edges = (_graphData.edges||[]).filter(e => nodeMap[e.source] && nodeMap[e.target]);
if (!nodes.length) {
ctx.fillStyle = '#6b7280';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Aucun lien inter-IP à afficher', W/2, H/2);
return;
}
// Simple force simulation (60 iterations)
for (let iter = 0; iter < 80; iter++) {
const alpha = 0.3 * (1 - iter/80);
// Repulsion
for (let i=0; i<nodes.length; i++) {
for (let j=i+1; j<nodes.length; j++) {
let dx = nodes[j].x - nodes[i].x;
let dy = nodes[j].y - nodes[i].y;
let d = Math.sqrt(dx*dx+dy*dy) || 1;
let f = 800 / (d*d) * alpha;
nodes[i].vx -= dx/d*f; nodes[i].vy -= dy/d*f;
nodes[j].vx += dx/d*f; nodes[j].vy += dy/d*f;
}
}
// Attraction (edges)
edges.forEach(e => {
const a = nodeMap[e.source], b = nodeMap[e.target];
if (!a || !b) return;
let dx = b.x-a.x, dy = b.y-a.y;
let d = Math.sqrt(dx*dx+dy*dy) || 1;
let f = (d-60)*0.01*alpha;
a.vx += dx/d*f; a.vy += dy/d*f;
b.vx -= dx/d*f; b.vy -= dy/d*f;
});
// Center gravity
nodes.forEach(n => {
n.vx += (W/2-n.x)*0.005*alpha;
n.vy += (H/2-n.y)*0.005*alpha;
n.x += n.vx; n.y += n.vy;
n.vx *= 0.8; n.vy *= 0.8;
n.x = Math.max(20, Math.min(W-20, n.x));
n.y = Math.max(20, Math.min(H-20, n.y));
});
}
// Draw
function draw() {
ctx.clearRect(0,0,W,H);
// Edges
ctx.strokeStyle = '#374151';
ctx.lineWidth = 0.5;
edges.forEach(e => {
const a = nodeMap[e.source], b = nodeMap[e.target];
if (!a || !b) return;
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
});
// Nodes
nodes.forEach(n => {
ctx.beginPath();
ctx.arc(n.x, n.y, n.radius, 0, Math.PI*2);
ctx.fillStyle = n.color + 'cc';
ctx.fill();
ctx.strokeStyle = n.color;
ctx.lineWidth = 1.5;
ctx.stroke();
});
}
draw();
// Hover tooltip
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const hit = nodes.find(n => Math.hypot(n.x-mx, n.y-my) <= n.radius+2);
if (hit) {
tip.style.display = 'block';
tip.style.left = (e.clientX - container.getBoundingClientRect().left + 12) + 'px';
tip.style.top = (e.clientY - container.getBoundingClientRect().top - 10) + 'px';
tip.innerHTML = `
<div class="font-mono text-purple-300">${fmtIP(hit.id)}</div>
<div>Campagne #${hit.group}</div>
<div>JA4: ${escapeHtml((hit.ja4||'').substring(0,25))}</div>
<div>ASN: ${escapeHtml(hit.asn_org||'—')}</div>
<div>Pays: ${fmtCountry(hit.country)}</div>
<div>Hits: ${hit.total_hits}</div>
`;
} else {
tip.style.display = 'none';
}
});
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const hit = nodes.find(n => Math.hypot(n.x-mx, n.y-my) <= n.radius+2);
if (hit) window.location.href = `/ip/${encodeURIComponent(String(hit.id||'').replace('::ffff:',''))}`;
});
canvas.style.cursor = 'default';
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
canvas.style.cursor = nodes.find(n => Math.hypot(n.x-mx,n.y-my)<=n.radius+2) ? 'pointer' : 'default';
});
}
/* ════════════════════════════════════════════════════════════════════════════
* Campaign detail panel
* ════════════════════════════════════════════════════════════════════════════ */
async function selectCampaign(cid) {
// Highlight card
document.querySelectorAll('.camp-card').forEach(c => c.classList.toggle('active', c.dataset.cid == cid));
const panel = document.getElementById('detail-panel');
panel.classList.add('open');
document.getElementById('detail-cid').textContent = `#${cid}`;
document.getElementById('detail-link').href = `/cluster/${cid}`;
try {
const resp = await fetch(`/api/campaigns/${cid}?days=${_currentDays}`);
const data = await resp.json();
const p = data.profile || {};
const members = data.members || [];
const timeline = data.timeline || [];
// Detail KPIs
document.getElementById('detail-kpis').innerHTML = [
['IPs uniques', p.unique_ips||0, 'text-purple-400'],
['JA4 uniques', p.unique_ja4||0, 'text-cyan-400'],
['Hosts ciblés', p.unique_hosts||0, 'text-amber-400'],
['ASNs', p.unique_asns||0, 'text-blue-400'],
['Score moyen', (p.avg_score||0).toFixed(3), 'text-red-400'],
['Score max', (p.max_score||0).toFixed(3), 'text-red-300'],
].map(([label,val,cls]) => `
<div class="px-4 py-3 text-center">
<div class="text-lg font-bold ${cls}">${val}</div>
<div class="text-[10px] text-gray-500 uppercase">${label}</div>
</div>
`).join('');
// Shared attributes
const attrsEl = document.getElementById('detail-attrs');
const ja4List = (p.ja4_list||[]).slice(0,15);
const asnList = (p.asn_list||[]).slice(0,8);
const countryList = (p.country_list||[]).slice(0,10);
const hostList = (p.host_list||[]).slice(0,8);
attrsEl.innerHTML = `
<div>
<div class="text-[10px] text-gray-500 uppercase mb-1">JA4 signatures (${ja4List.length})</div>
<div class="flex flex-wrap gap-1">${ja4List.map(j=>`<span class="font-mono text-[10px] bg-gray-800 px-1.5 py-0.5 rounded text-cyan-300">${escapeHtml(j).substring(0,25)}…</span>`).join('')}</div>
</div>
<div>
<div class="text-[10px] text-gray-500 uppercase mb-1">ASN organisations</div>
<div class="flex flex-wrap gap-1">${asnList.map(a=>`<span class="text-[10px] bg-gray-800 px-1.5 py-0.5 rounded text-blue-300">${escapeHtml(a)}</span>`).join('')||'—'}</div>
</div>
<div>
<div class="text-[10px] text-gray-500 uppercase mb-1">Pays</div>
<div class="flex flex-wrap gap-1">${countryList.map(c=>`<span class="text-[10px] bg-gray-800 px-1.5 py-0.5 rounded">${fmtCountry(c)}</span>`).join('')||'—'}</div>
</div>
<div>
<div class="text-[10px] text-gray-500 uppercase mb-1">Hosts ciblés</div>
<div class="flex flex-wrap gap-1">${hostList.map(h=>`<span class="text-[10px] bg-gray-800 px-1.5 py-0.5 rounded text-amber-300">${escapeHtml(h)}</span>`).join('')||'—'}</div>
</div>
`;
// Member count
document.getElementById('member-count').textContent = `(${members.length})`;
// Members table
document.getElementById('members-body').innerHTML = members.map(m => {
const rawIP = String(m.src_ip||'').replace('::ffff:','');
return `
<tr class="hover:bg-gray-800/40 cursor-pointer" onclick="window.location='/ip/${encodeURIComponent(rawIP)}'">
<td class="px-3 py-2 font-mono text-purple-300">${fmtIP(m.src_ip)}</td>
<td class="px-3 py-2 font-mono text-[10px] text-gray-400 max-w-[140px] truncate">${escapeHtml(m.ja4||'')}</td>
<td class="px-3 py-2 text-gray-300">${escapeHtml(m.host||'')}</td>
<td class="px-3 py-2 text-right">${fmtScore(m.anomaly_score)}</td>
<td class="px-3 py-2">${threatBadge(m.threat_level)}</td>
<td class="px-3 py-2 text-right text-gray-300">${m.hits||0}</td>
<td class="px-3 py-2 text-right text-amber-300">${(parseFloat(m.hit_velocity)||0).toFixed(1)}</td>
<td class="px-3 py-2 text-gray-400 max-w-[120px] truncate">${escapeHtml(m.asn_org||'—')}</td>
<td class="px-3 py-2">${fmtCountry(m.country_code)}</td>
<td class="px-3 py-2 text-gray-500 text-[10px]">${m.detected_at||''}</td>
</tr>
`;
}).join('') || '<tr><td colspan="10" class="text-center py-6 text-gray-500">Aucun membre</td></tr>';
// Radar chart
renderRadar(p);
// Timeline chart
renderTimeline(timeline, cid);
// Scroll to detail
panel.scrollIntoView({ behavior:'smooth', block:'start' });
} catch(e) {
console.error('Campaign detail error:', e);
}
}
function closeDetail() {
document.getElementById('detail-panel').classList.remove('open');
document.querySelectorAll('.camp-card').forEach(c => c.classList.remove('active'));
}
/* ════════════════════════════════════════════════════════════════════════════
* Radar chart (behavioral profile)
* ════════════════════════════════════════════════════════════════════════════ */
function renderRadar(profile) {
const canvas = document.getElementById('radar-chart');
if (_radarChart) _radarChart.destroy();
const labels = ['Vélocité', 'Fuzzing', 'POST ratio', 'Port exhaust.', 'Orphan ratio', 'Score anomalie'];
const raw = [
parseFloat(profile.avg_velocity)||0,
parseFloat(profile.avg_fuzzing)||0,
parseFloat(profile.avg_post_ratio)||0,
parseFloat(profile.avg_port_exhaustion)||0,
parseFloat(profile.avg_orphan)||0,
Math.abs(parseFloat(profile.avg_score)||0),
];
// Normalize each to [0,1] with reasonable caps
const caps = [50, 1, 1, 1, 1, 1];
const normalized = raw.map((v,i) => Math.min(v/caps[i], 1));
_radarChart = new Chart(canvas, {
type: 'radar',
data: {
labels,
datasets: [{
label: 'Campagne',
data: normalized,
backgroundColor: 'rgba(129,140,248,0.2)',
borderColor: '#818cf8',
borderWidth: 2,
pointBackgroundColor: '#818cf8',
pointRadius: 3,
}],
},
options: {
responsive: true, maintainAspectRatio: true,
scales: {
r: {
min: 0, max: 1,
ticks: { display:false, stepSize:0.25 },
grid: { color:'#1f293788' },
angleLines: { color:'#1f293788' },
pointLabels: { color:'#9ca3af', font:{size:10} },
},
},
plugins: {
legend: { display:false },
tooltip: {
callbacks: {
label: ctx => `${labels[ctx.dataIndex]}: ${raw[ctx.dataIndex].toFixed(3)} (norm: ${normalized[ctx.dataIndex].toFixed(2)})`
}
},
},
},
});
}
/* ════════════════════════════════════════════════════════════════════════════
* Timeline chart (hourly detections)
* ════════════════════════════════════════════════════════════════════════════ */
function renderTimeline(timeline, cid) {
const canvas = document.getElementById('timeline-chart');
if (_timelineChart) _timelineChart.destroy();
if (!timeline.length) {
const ctx2 = canvas.getContext('2d');
ctx2.clearRect(0,0,canvas.width,canvas.height);
ctx2.fillStyle = '#6b7280'; ctx2.font = '12px sans-serif'; ctx2.textAlign = 'center';
ctx2.fillText('Pas de données temporelles', canvas.width/2, canvas.height/2);
return;
}
const labels = timeline.map(t => {
const d = new Date(t.hour);
return d.toLocaleDateString('fr-FR',{day:'2-digit',month:'short'}) + ' ' + d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
});
const color = campColor(cid);
_timelineChart = new Chart(canvas, {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'Détections',
data: timeline.map(t=>t.detections),
backgroundColor: color + '88',
borderColor: color,
borderWidth: 1,
yAxisID: 'y',
},
{
label: 'IPs actives',
data: timeline.map(t=>t.active_ips),
type: 'line',
borderColor: '#34d399',
backgroundColor: '#34d39922',
borderWidth: 2,
pointRadius: 2,
fill: true,
yAxisID: 'y1',
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: { ticks:{color:'#6b7280',font:{size:9},maxRotation:45}, grid:{display:false} },
y: { position:'left', title:{display:true,text:'Détections',color:'#9ca3af',font:{size:10}},
ticks:{color:'#6b7280',font:{size:10}}, grid:{color:'#1f293744'} },
y1: { position:'right', title:{display:true,text:'IPs',color:'#34d399',font:{size:10}},
ticks:{color:'#34d399',font:{size:10}}, grid:{display:false} },
},
plugins: {
legend: { position:'bottom', labels:{color:'#9ca3af',boxWidth:10,font:{size:10}} },
},
},
});
}
/* ════════════════════════════════════════════════════════════════════════════
* Init
* ════════════════════════════════════════════════════════════════════════════ */
loadAll();
</script>
{% endblock %}