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,

View File

@ -40,6 +40,14 @@
Campagnes de bots
</h1>
<div class="ml-auto flex items-center gap-3">
<!-- ── Sélecteur de plage temporelle ── -->
<div class="flex items-center gap-1 bg-gray-800/60 rounded-lg p-1" id="days-selector">
<button onclick="setDays(1)" data-days="1" class="days-btn px-2 py-1 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors">1j</button>
<button onclick="setDays(7)" data-days="7" class="days-btn px-2 py-1 rounded text-xs text-white bg-purple-600">7j</button>
<button onclick="setDays(14)" data-days="14" class="days-btn px-2 py-1 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors">14j</button>
<button onclick="setDays(30)" data-days="30" class="days-btn px-2 py-1 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors">30j</button>
<button onclick="setDays(90)" data-days="90" class="days-btn px-2 py-1 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors">90j</button>
</div>
<div class="text-center px-3">
<div class="text-2xl font-bold text-purple-400" id="kpi-total"></div>
<div class="text-[10px] text-gray-500 uppercase tracking-wider">Campagnes</div>
@ -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 || [];