feat(dashboard): sélecteur de plage temporelle sur /campaigns

Avant : toutes les vues de campagnes étaient fixes à 7 jours.
Après : sélecteur 1j / 7j (défaut) / 14j / 30j / 90j en haut à droite.

- Ajout du paramètre ?days= (1–90, défaut 7) à :
    GET /api/campaigns
    GET /api/campaigns/graph
    GET /api/campaigns/scatter
    GET /api/campaigns/{cid}
- Le sélecteur recharge simultanément les 3 vues (cartes, scatter, graphe)
  et le panneau de détail avec la même fenêtre temporelle
- Le compteur de campagnes indique la plage active : (4 campagnes — 30j)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-10 13:24:08 +02:00
parent 9548b1782d
commit 79dbb23d6f
2 changed files with 54 additions and 24 deletions

View File

@ -911,8 +911,9 @@ async def classifications() -> dict[str, Any]:
# GET /api/campaigns — HDBSCAN bot campaign clusters
# ---------------------------------------------------------------------------
@router.get("/campaigns")
async def campaigns() -> dict[str, Any]:
async def campaigns(days: int = 7) -> dict[str, Any]:
"""Campagnes de bots détectées par clustering HDBSCAN."""
days = max(1, min(days, 90))
try:
rows = query(
f"SELECT campaign_id, "
@ -930,9 +931,10 @@ async def campaigns() -> dict[str, Any]:
f"avg(post_ratio) AS avg_post_ratio "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id >= 0 "
"AND detected_at >= now() - INTERVAL 7 DAY "
"AND detected_at >= now() - INTERVAL {days:UInt16} DAY "
"GROUP BY campaign_id "
"ORDER BY members DESC LIMIT 50"
"ORDER BY members DESC LIMIT 50",
{"days": days},
)
return {"campaigns": rows}
except Exception as exc:
@ -944,8 +946,9 @@ async def campaigns() -> dict[str, Any]:
# GET /api/campaigns/graph — Network graph data (shared JA4/ASN links)
# ---------------------------------------------------------------------------
@router.get("/campaigns/graph")
async def campaigns_graph() -> dict[str, Any]:
async def campaigns_graph(days: int = 7) -> dict[str, Any]:
"""Données de graphe réseau : nœuds (IPs) et liens (JA4/ASN partagés)."""
days = max(1, min(days, 90))
try:
# Nœuds : chaque IP avec ses attributs principaux
nodes = query(
@ -958,10 +961,11 @@ async def campaigns_graph() -> dict[str, Any]:
f"min(anomaly_score) AS worst_score "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id >= 0 "
"AND detected_at >= now() - INTERVAL 7 DAY "
"AND detected_at >= now() - INTERVAL {days:UInt16} DAY "
"GROUP BY src_ip, campaign_id "
"ORDER BY campaign_id, worst_score ASC "
"LIMIT 500"
"LIMIT 500",
{"days": days},
)
# Liens : IPs partageant le même JA4 dans la même campagne
edges = query(
@ -974,9 +978,10 @@ async def campaigns_graph() -> dict[str, Any]:
"ON a.ja4 = b.ja4 AND a.campaign_id = b.campaign_id "
"WHERE a.campaign_id >= 0 "
"AND a.src_ip < b.src_ip "
"AND a.detected_at >= now() - INTERVAL 7 DAY "
"AND b.detected_at >= now() - INTERVAL 7 DAY "
"LIMIT 2000"
"AND a.detected_at >= now() - INTERVAL {days:UInt16} DAY "
"AND b.detected_at >= now() - INTERVAL {days:UInt16} DAY "
"LIMIT 2000",
{"days": days},
)
return {"nodes": nodes, "edges": edges}
except Exception as exc:
@ -988,8 +993,9 @@ async def campaigns_graph() -> dict[str, Any]:
# GET /api/campaigns/scatter — Scatter plot data (score vs velocity per IP)
# ---------------------------------------------------------------------------
@router.get("/campaigns/scatter")
async def campaigns_scatter() -> 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."""
days = max(1, min(days, 90))
try:
rows = query(
f"SELECT toString(src_ip) AS ip, "
@ -1002,9 +1008,10 @@ async def campaigns_scatter() -> dict[str, Any]:
f"any(threat_level) AS threat "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id >= 0 "
"AND detected_at >= now() - INTERVAL 7 DAY "
"AND detected_at >= now() - INTERVAL {days:UInt16} DAY "
"GROUP BY src_ip, campaign_id "
"ORDER BY score ASC LIMIT 500"
"ORDER BY score ASC LIMIT 500",
{"days": days},
)
return {"data": rows}
except Exception as exc:
@ -1016,8 +1023,9 @@ async def campaigns_scatter() -> dict[str, Any]:
# GET /api/campaigns/{cid} — Campaign detail (member IPs + features)
# ---------------------------------------------------------------------------
@router.get("/campaigns/{cid}")
async def campaign_detail(cid: int) -> dict[str, Any]:
async def campaign_detail(cid: int, days: int = 7) -> dict[str, Any]:
"""Détail d'une campagne : IPs membres, features comportementales, timeline."""
days = max(1, min(days, 90))
try:
members = query(
f"SELECT toString(src_ip) AS src_ip, ja4, host, "
@ -1028,9 +1036,9 @@ async def campaign_detail(cid: int) -> dict[str, Any]:
f"browser_family, bot_name, detected_at, reason "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id = {cid:Int32} "
"AND detected_at >= now() - INTERVAL 7 DAY "
"AND detected_at >= now() - INTERVAL {days:UInt16} DAY "
"ORDER BY anomaly_score ASC LIMIT 200",
{"cid": cid},
{"cid": cid, "days": days},
)
# Profil agrégé de la campagne
profile = query(
@ -1049,8 +1057,8 @@ async def campaign_detail(cid: int) -> dict[str, Any]:
f"min(detected_at) AS first_seen, max(detected_at) AS last_seen "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id = {cid:Int32} "
"AND detected_at >= now() - INTERVAL 7 DAY",
{"cid": cid},
"AND detected_at >= now() - INTERVAL {days:UInt16} DAY",
{"cid": cid, "days": days},
)
# Timeline horaire de la campagne
timeline = query(
@ -1058,9 +1066,9 @@ async def campaign_detail(cid: int) -> dict[str, Any]:
f"count() AS detections, uniqExact(src_ip) AS active_ips "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id = {cid:Int32} "
"AND detected_at >= now() - INTERVAL 7 DAY "
"AND detected_at >= now() - INTERVAL {days:UInt16} DAY "
"GROUP BY hour ORDER BY hour",
{"cid": cid},
{"cid": cid, "days": days},
)
return {
"campaign_id": cid,