diff --git a/services/dashboard/backend/routes/api.py b/services/dashboard/backend/routes/api.py index 6857303..9d7049c 100644 --- a/services/dashboard/backend/routes/api.py +++ b/services/dashboard/backend/routes/api.py @@ -990,27 +990,30 @@ async def campaigns_graph(days: int = 7) -> dict[str, Any]: # --------------------------------------------------------------------------- -# GET /api/campaigns/scatter — Scatter plot data (score vs velocity per IP) +# GET /api/campaigns/scatter — Scatter plot : une bulle par campagne # --------------------------------------------------------------------------- @router.get("/campaigns/scatter") async def campaigns_scatter(days: int = 7) -> dict[str, Any]: - """Données scatter plot : score vs vélocité par IP, coloré par campagne.""" + """Données scatter plot : score vs vélocité agrégés par campagne.""" days = max(1, min(days, 90)) try: rows = query( - f"SELECT toString(src_ip) AS ip, " - f"campaign_id, " - f"min(anomaly_score) AS score, " - f"avg(hit_velocity) AS velocity, " + f"SELECT campaign_id, " + f"avg(anomaly_score) AS avg_score, " + f"avg(hit_velocity) AS avg_velocity, " f"sum(hits) AS total_hits, " - f"any(ja4) AS ja4, " - f"any(asn_org) AS asn_org, " - f"any(threat_level) AS threat " + f"uniqExact(src_ip) AS unique_ips, " + f"groupUniqArray(5)(ja4) AS ja4_list, " + f"groupUniqArray(3)(asn_org) AS asn_list, " + f"groupUniqArray(3)(country_code) AS country_list, " + f"any(threat_level) AS threat, " + f"min(detected_at) AS first_seen, " + f"max(detected_at) AS last_seen " f"FROM {_DB}.ml_detected_anomalies " "WHERE campaign_id >= 0 " "AND detected_at >= now() - INTERVAL {days:UInt16} DAY " - "GROUP BY src_ip, campaign_id " - "ORDER BY score ASC LIMIT 500", + "GROUP BY campaign_id " + "ORDER BY avg_score ASC LIMIT 100", {"days": days}, ) return {"data": rows} diff --git a/services/dashboard/backend/templates/campaigns.html b/services/dashboard/backend/templates/campaigns.html index 5b46f93..254e09e 100644 --- a/services/dashboard/backend/templates/campaigns.html +++ b/services/dashboard/backend/templates/campaigns.html @@ -84,10 +84,10 @@ Carte des clusters

Scatter — Score vs Vélocité

-

Chaque bulle = une IP. Position : score d'anomalie (X) vs vitesse de requêtes (Y). - Taille = nombre de hits. Couleur = campagne.

-

Lecture : Les clusters visuels correspondent aux campagnes HDBSCAN. - Les IPs éloignées du cluster principal sont les plus suspectes.

+

Chaque bulle = une campagne. Position : score d'anomalie moyen (X) vs vitesse de requêtes moyenne (Y). + Taille = nombre d'IPs. Couleur = campagne.

+

Lecture : Les campagnes proches partagent des comportements similaires. + Cliquer sur une bulle ouvre le détail de la campagne.

Source : ml_detected_anomalies WHERE campaign_id ≥ 0

@@ -328,27 +328,24 @@ function renderScatter() { const canvas = document.getElementById('scatter-chart'); if (_scatterChart) _scatterChart.destroy(); - // Group data by campaign_id - const groups = {}; - _scatterData.forEach(d => { + // One bubble per campaign (data is already campaign-level aggregates) + const datasets = _scatterData.map(d => { const cid = d.campaign_id; - if (!groups[cid]) groups[cid] = []; - groups[cid].push(d); + 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, + }; }); - 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 }, @@ -356,11 +353,11 @@ function renderScatter() { responsive: true, maintainAspectRatio: false, scales: { x: { - title: { display:true, text:'Score d\'anomalie', color:'#9ca3af', font:{size:11} }, + 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é (req/s)', color:'#9ca3af', font:{size:11} }, + title: { display:true, text:'Vélocité moy. (req/s)', color:'#9ca3af', font:{size:11} }, grid: { color:'#1f293766' }, ticks: { color:'#6b7280', font:{size:10} }, }, }, @@ -368,15 +365,23 @@ function renderScatter() { 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; - 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||'—'}`, + 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; } } }, @@ -384,7 +389,7 @@ function renderScatter() { onClick: (e, els) => { if (els.length) { const m = els[0].element.$context.raw._meta; - window.location.href = `/ip/${encodeURIComponent(String(m.ip||'').replace('::ffff:',''))}`; + selectCampaign(m.campaign_id); } }, },