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>
This commit is contained in:
721
services/dashboard/backend/templates/campaigns.html
Normal file
721
services/dashboard/backend/templates/campaigns.html
Normal file
@ -0,0 +1,721 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user