- 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>
754 lines
40 KiB
HTML
754 lines
40 KiB
HTML
{% 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 %}
|