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:
@ -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}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user