Files
ja4-platform/services/dashboard/backend/templates/campaigns.html
toto 396baa90d2 feat(dashboard): visualisation clusters HDBSCAN
- Page /campaigns dédiée avec 4 vues graphiques :
  · Scatter plot (score vs vélocité, bulles colorées par campagne)
  · Graphe réseau force-directed (IPs liées par JA4 partagé)
  · Grille de cartes campagne (KPIs, ASN, pays, JA4)
  · Panneau détail (radar comportemental, timeline horaire, table membres)
- 4 nouveaux endpoints API :
  · GET /api/campaigns (fix: campaign_id >= 0 au lieu de != '')
  · GET /api/campaigns/graph (nœuds + arêtes)
  · GET /api/campaigns/scatter (score/vélocité par IP)
  · GET /api/campaigns/{cid} (détail + profil + timeline)
- Sidebar: lien Campagnes ajouté dans Surveillance
- Overview: campagnes clickables → lien vers /campaigns

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

722 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 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">
<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 IP. Position : score d'anomalie (X) vs vitesse de requêtes (Y).
Taille = nombre de hits. Couleur = campagne.</p>
<p><strong>Lecture :</strong> Les clusters visuels correspondent aux campagnes HDBSCAN.
Les IPs éloignées du cluster principal sont les plus suspectes.</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>
</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;
async function loadAll() {
try {
const [campResp, scatterResp, graphResp] = await Promise.all([
fetch('/api/campaigns').then(r=>r.json()),
fetch('/api/campaigns/scatter').then(r=>r.json()),
fetch('/api/campaigns/graph').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();
document.getElementById('camp-count').textContent = `(${_campaigns.length} actives)`;
renderCampGrid();
renderScatter();
renderGraph();
} catch(e) {
console.error('Campaign load error:', e);
}
}
/* ════════════════════════════════════════════════════════════════════════════
* 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();
// Group data by campaign_id
const groups = {};
_scatterData.forEach(d => {
const cid = d.campaign_id;
if (!groups[cid]) groups[cid] = [];
groups[cid].push(d);
});
const datasets = Object.entries(groups).map(([cid, points]) => ({
label: `Camp. #${cid}`,
data: points.map(p => ({
x: parseFloat(p.score)||0,
y: parseFloat(p.velocity)||0,
r: Math.max(3, Math.min(20, Math.sqrt(parseInt(p.total_hits)||1))),
_meta: p,
})),
backgroundColor: campColor(parseInt(cid)) + '88',
borderColor: campColor(parseInt(cid)),
borderWidth: 1,
}));
_scatterChart = new Chart(canvas, {
type: 'bubble',
data: { datasets },
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: {
title: { display:true, text:'Score d\'anomalie', color:'#9ca3af', font:{size:11} },
grid: { color:'#1f293766' }, ticks: { color:'#6b7280', font:{size:10} },
},
y: {
title: { display:true, text:'Vélocité (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: {
label: ctx => {
const m = ctx.raw._meta;
return [
`IP: ${fmtIP(m.ip)}`,
`Score: ${(parseFloat(m.score)||0).toFixed(4)}`,
`Vélocité: ${(parseFloat(m.velocity)||0).toFixed(1)} r/s`,
`Hits: ${m.total_hits}`,
`ASN: ${m.asn_org||'—'}`,
];
}
}
},
},
onClick: (e, els) => {
if (els.length) {
const m = els[0].element.$context.raw._meta;
window.location.href = `/ip/${encodeURIComponent(fmtIP(m.ip))}`;
}
},
},
});
}
/* ════════════════════════════════════════════════════════════════════════════
* 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(fmtIP(hit.id))}`;
});
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}`;
try {
const resp = await fetch(`/api/campaigns/${cid}`);
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 => `
<tr class="hover:bg-gray-800/40 cursor-pointer" onclick="window.location='/ip/${encodeURIComponent(fmtIP(m.src_ip))}'">
<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 %}