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