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")
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}

View File

@ -84,10 +84,10 @@
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>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>
@ -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);
}
},
},