@ -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 : 1 px solid #374151 ;
border-radius : 8 px ; padding : 8 px 12 px ; font-size : 11 px ; color : #e5e7eb ;
pointer-events : none ; z-index : 100 ; max-width : 260 px ; line-height : 1.5 ;
box-shadow : 0 4 px 12 px rgba ( 0 , 0 , 0 , 0.4 ) ;
}
/* ── Campaign cards ── */
. camp-card {
background : #111827 ; border : 1 px solid #1f2937 ; border-radius : 10 px ;
padding : 16 px ; cursor : pointer ; transition : all 0.2 s ;
}
. camp-card : hover { border-color : #6366f1 ; background : #1e1b4b 22 ; }
. camp-card . active { border-color : #818cf8 ; background : #1e1b4b 33 ; box-shadow : 0 0 0 1 px #818cf8 ; }
. camp-chip {
display : inline-flex ; align-items : center ; gap : 4 px ;
padding : 2 px 8 px ; border-radius : 9999 px ; font-size : 10 px ; 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 : 320 px ; margin : 0 auto ; }
/* ── Scatter bubbles ── */
. scatter-legend { display : flex ; flex-wrap : wrap ; gap : 8 px ; margin-top : 8 px ; }
. scatter-legend-item { display : flex ; align-items : center ; gap : 4 px ; font-size : 10 px ; color : #9ca3af ; }
. scatter-dot { width : 10 px ; height : 10 px ; 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 %}