fix(dashboard): campaigns scatter chart — show campaigns not IPs

- 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>
This commit is contained in:
toto
2026-04-10 15:09:02 +02:00
parent fb73c60e7d
commit 261205028d
2 changed files with 50 additions and 42 deletions

View File

@ -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") @router.get("/campaigns/scatter")
async def campaigns_scatter(days: int = 7) -> dict[str, Any]: 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)) days = max(1, min(days, 90))
try: try:
rows = query( rows = query(
f"SELECT toString(src_ip) AS ip, " f"SELECT campaign_id, "
f"campaign_id, " f"avg(anomaly_score) AS avg_score, "
f"min(anomaly_score) AS score, " f"avg(hit_velocity) AS avg_velocity, "
f"avg(hit_velocity) AS velocity, "
f"sum(hits) AS total_hits, " f"sum(hits) AS total_hits, "
f"any(ja4) AS ja4, " f"uniqExact(src_ip) AS unique_ips, "
f"any(asn_org) AS asn_org, " f"groupUniqArray(5)(ja4) AS ja4_list, "
f"any(threat_level) AS threat " 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 " f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id >= 0 " "WHERE campaign_id >= 0 "
"AND detected_at >= now() - INTERVAL {days:UInt16} DAY " "AND detected_at >= now() - INTERVAL {days:UInt16} DAY "
"GROUP BY src_ip, campaign_id " "GROUP BY campaign_id "
"ORDER BY score ASC LIMIT 500", "ORDER BY avg_score ASC LIMIT 100",
{"days": days}, {"days": days},
) )
return {"data": rows} return {"data": rows}

View File

@ -84,10 +84,10 @@
Carte des clusters Carte des clusters
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel"> <span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn"></button><div class="doc-panel">
<h4>Scatter — Score vs Vélocité</h4> <h4>Scatter — Score vs Vélocité</h4>
<p>Chaque bulle = une IP. Position : score d'anomalie (X) vs vitesse de requêtes (Y). <p>Chaque bulle = une campagne. Position : score d'anomalie moyen (X) vs vitesse de requêtes moyenne (Y).
Taille = nombre de hits. Couleur = campagne.</p> Taille = nombre d'IPs. Couleur = campagne.</p>
<p><strong>Lecture :</strong> Les clusters visuels correspondent aux campagnes HDBSCAN. <p><strong>Lecture :</strong> Les campagnes proches partagent des comportements similaires.
Les IPs éloignées du cluster principal sont les plus suspectes.</p> 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> <p class="doc-source">Source : ml_detected_anomalies WHERE campaign_id ≥ 0</p>
</div></span> </div></span>
</span> </span>
@ -328,27 +328,24 @@ function renderScatter() {
const canvas = document.getElementById('scatter-chart'); const canvas = document.getElementById('scatter-chart');
if (_scatterChart) _scatterChart.destroy(); if (_scatterChart) _scatterChart.destroy();
// Group data by campaign_id // One bubble per campaign (data is already campaign-level aggregates)
const groups = {}; const datasets = _scatterData.map(d => {
_scatterData.forEach(d => {
const cid = d.campaign_id; const cid = d.campaign_id;
if (!groups[cid]) groups[cid] = []; const color = campColor(parseInt(cid));
groups[cid].push(d); 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, { _scatterChart = new Chart(canvas, {
type: 'bubble', type: 'bubble',
data: { datasets }, data: { datasets },
@ -356,11 +353,11 @@ function renderScatter() {
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
scales: { scales: {
x: { 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} }, grid: { color:'#1f293766' }, ticks: { color:'#6b7280', font:{size:10} },
}, },
y: { 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} }, 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}} }, legend: { display:true, position:'bottom', labels:{color:'#9ca3af',boxWidth:10,font:{size:10}} },
tooltip: { tooltip: {
callbacks: { callbacks: {
title: ctx => {
const m = ctx[0]?.raw?._meta;
return m ? `Campagne #${m.campaign_id}` : '';
},
label: ctx => { label: ctx => {
const m = ctx.raw._meta; const m = ctx.raw._meta;
return [ const lines = [
`IP: ${fmtIP(m.ip)}`, `IPs : ${m.unique_ips || '—'}`,
`Score: ${(parseFloat(m.score)||0).toFixed(4)}`, `Score moy. : ${(parseFloat(m.avg_score)||0).toFixed(4)}`,
`Vélocité: ${(parseFloat(m.velocity)||0).toFixed(1)} r/s`, `Vélocité moy. : ${(parseFloat(m.avg_velocity)||0).toFixed(1)} r/s`,
`Hits: ${m.total_hits}`, `Hits totaux : ${m.total_hits || '—'}`,
`ASN: ${m.asn_org||'—'}`, `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) => { onClick: (e, els) => {
if (els.length) { if (els.length) {
const m = els[0].element.$context.raw._meta; const m = els[0].element.$context.raw._meta;
window.location.href = `/ip/${encodeURIComponent(String(m.ip||'').replace('::ffff:',''))}`; selectCampaign(m.campaign_id);
} }
}, },
}, },