diff --git a/services/dashboard/backend/routes/api.py b/services/dashboard/backend/routes/api.py index b928ca9..b6508b2 100644 --- a/services/dashboard/backend/routes/api.py +++ b/services/dashboard/backend/routes/api.py @@ -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, diff --git a/services/dashboard/backend/templates/campaigns.html b/services/dashboard/backend/templates/campaigns.html index 9a49a6a..5b46f93 100644 --- a/services/dashboard/backend/templates/campaigns.html +++ b/services/dashboard/backend/templates/campaigns.html @@ -40,6 +40,14 @@ Campagnes de bots
+ +
+ + + + + +
Campagnes
@@ -238,13 +246,25 @@ function fmtCountry(cc) { * ════════════════════════════════════════════════════════════════════════════ */ let _campaigns = [], _scatterData = [], _graphData = {nodes:[],edges:[]}; let _scatterChart = null, _radarChart = null, _timelineChart = null; +let _currentDays = 7; + +function setDays(d) { + _currentDays = d; + document.querySelectorAll('.days-btn').forEach(b => { + const active = parseInt(b.dataset.days) === d; + b.className = `days-btn px-2 py-1 rounded text-xs transition-colors ${active ? 'text-white bg-purple-600' : 'text-gray-400 hover:text-white hover:bg-gray-700'}`; + }); + closeDetail(); + loadAll(); +} async function loadAll() { + document.getElementById('camp-count').textContent = '(chargement…)'; try { const [campResp, scatterResp, graphResp] = await Promise.all([ - fetch('/api/campaigns').then(r=>r.json()), - fetch('/api/campaigns/scatter').then(r=>r.json()), - fetch('/api/campaigns/graph').then(r=>r.json()), + fetch(`/api/campaigns?days=${_currentDays}`).then(r=>r.json()), + fetch(`/api/campaigns/scatter?days=${_currentDays}`).then(r=>r.json()), + fetch(`/api/campaigns/graph?days=${_currentDays}`).then(r=>r.json()), ]); _campaigns = campResp.campaigns || []; _scatterData = scatterResp.data || []; @@ -256,13 +276,15 @@ async function loadAll() { document.getElementById('kpi-total').textContent = _campaigns.length; document.getElementById('kpi-ips').textContent = totalIPs.toLocaleString(); document.getElementById('kpi-detections').textContent = totalDet.toLocaleString(); - document.getElementById('camp-count').textContent = `(${_campaigns.length} actives)`; + const label = _currentDays === 1 ? '24h' : `${_currentDays}j`; + document.getElementById('camp-count').textContent = `(${_campaigns.length} campagne${_campaigns.length!==1?'s':''} — ${label})`; renderCampGrid(); renderScatter(); renderGraph(); } catch(e) { console.error('Campaign load error:', e); + document.getElementById('camp-count').textContent = '(erreur)'; } } @@ -511,7 +533,7 @@ async function selectCampaign(cid) { document.getElementById('detail-link').href = `/cluster/${cid}`; try { - const resp = await fetch(`/api/campaigns/${cid}`); + const resp = await fetch(`/api/campaigns/${cid}?days=${_currentDays}`); const data = await resp.json(); const p = data.profile || {}; const members = data.members || [];