feat(dashboard): complete SOC dashboard with full monitoring and workflows

- models.html: Full rewrite — 6 KPIs, scoring volume timeline, anomaly rate
  chart, threat breakdown per model, enhanced model cards with validation gate
- classify.html: SOC workflow — suggested unclassified IPs, quick-classify
  buttons, classification stats pie, pre-fill from URL params
- traffic.html: Clickable rows → ip_detail, column sorting, status column,
  search filter, doc tooltips on all chart sections
- scores.html: Search input, clickable rows → ip_detail, LEGITIMATE_BROWSER
  filter button, doc tooltips on distribution + scatter charts
- ip_detail.html: Resource cascade section (headless browser detection),
  status column in HTTP logs table
- detections.html: Doc tooltips on threat/reason/ASN chart sections
- features.html: Doc tooltips on radar/importance/scatter sections
- api.py: 4 new endpoints — /api/models/timeline, /api/models/threats,
  /api/classify/stats, /api/classify/suggested. Traffic API: status + search.

46 routes total. All tests pass (dashboard + bot-detector 36/36).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-09 01:25:01 +02:00
parent 396baa90d2
commit 63ba6d203c
8 changed files with 711 additions and 142 deletions

View File

@ -35,7 +35,7 @@ _SCORE_SORT_COLS = {
"asn_org", "country_code", "browser_family",
}
_TRAFFIC_SORT_COLS = {
"time", "src_ip", "method", "host", "path", "http_version",
"time", "src_ip", "method", "status", "host", "path", "http_version",
"header_user_agent", "ja4", "src_country_code",
}
_ORDER_VALUES = {"ASC", "DESC"}
@ -315,6 +315,8 @@ async def traffic(
method: str | None = Query(None),
host: str | None = Query(None),
http_version: str | None = Query(None),
status: int | None = Query(None),
search: str | None = Query(None),
) -> dict[str, Any]:
sort = _validate_sort(sort, _TRAFFIC_SORT_COLS, "time")
order = _validate_order(order)
@ -335,6 +337,18 @@ async def traffic(
where_clauses.append("http_version = {http_version:String}")
params["http_version"] = http_version
if status is not None:
where_clauses.append("status = {status:UInt16}")
params["status"] = status
if search:
where_clauses.append(
"(toString(src_ip) LIKE {search:String} "
"OR path LIKE {search:String} "
"OR header_user_agent LIKE {search:String})"
)
params["search"] = f"%{search}%"
where = " AND ".join(where_clauses)
try:
@ -344,7 +358,7 @@ async def traffic(
)
rows = query(
f"SELECT time, toString(src_ip) AS src_ip, method, host, path, "
f"SELECT time, toString(src_ip) AS src_ip, method, status, host, path, "
f"http_version, header_user_agent, ja4, src_country_code "
f"FROM {_DB_LOGS}.http_logs "
f"WHERE {where} ORDER BY {sort} {order} "
@ -392,7 +406,7 @@ async def ip_detail(ip: str) -> dict[str, Any]:
)
http_logs = query(
f"SELECT time, method, host, path, http_version, header_user_agent, ja4 "
f"SELECT time, method, status, host, path, http_version, header_user_agent, ja4 "
f"FROM {_DB_LOGS}.http_logs "
"WHERE src_ip = toIPv4OrZero({ip:String}) "
"AND time >= now() - INTERVAL 1 DAY "
@ -739,6 +753,98 @@ async def models() -> dict[str, Any]:
return {"models": model_info, "scoring_stats": scoring_stats}
# ---------------------------------------------------------------------------
# GET /api/models/timeline — Scoring volume over time per model
# ---------------------------------------------------------------------------
@router.get("/models/timeline")
async def models_timeline() -> dict[str, Any]:
"""Volume de scoring horaire par modèle (7 jours)."""
try:
rows = query(
f"SELECT toStartOfHour(detected_at) AS hour, "
f"model_name, count() AS cnt, "
f"avg(anomaly_score) AS avg_score, "
f"countIf(threat_level IN ('HIGH','CRITICAL')) AS anomalies "
f"FROM {_DB}.ml_all_scores "
"WHERE detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY hour, model_name "
"ORDER BY hour"
)
return {"timeline": rows}
except Exception as exc:
logger.exception("models timeline query failed")
return {"timeline": []}
# ---------------------------------------------------------------------------
# GET /api/models/threats — Threat breakdown per model
# ---------------------------------------------------------------------------
@router.get("/models/threats")
async def models_threats() -> dict[str, Any]:
"""Répartition des niveaux de menace par modèle."""
try:
rows = query(
f"SELECT model_name, threat_level, count() AS cnt "
f"FROM {_DB}.ml_all_scores "
"WHERE detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY model_name, threat_level "
"ORDER BY model_name, cnt DESC"
)
return {"threats": rows}
except Exception as exc:
logger.exception("models threats query failed")
return {"threats": []}
# ---------------------------------------------------------------------------
# GET /api/classify/stats — Classification summary stats
# ---------------------------------------------------------------------------
@router.get("/classify/stats")
async def classify_stats() -> dict[str, Any]:
"""Statistiques de classification SOC."""
try:
_ensure_feedback_table()
rows = query(
f"SELECT classification, count() AS cnt "
f"FROM {_DB}.soc_feedback "
"GROUP BY classification"
)
total = sum(r["cnt"] for r in rows)
return {"stats": rows, "total": total}
except Exception:
return {"stats": [], "total": 0}
# ---------------------------------------------------------------------------
# GET /api/classify/suggested — Top unclassified IPs
# ---------------------------------------------------------------------------
@router.get("/classify/suggested")
async def classify_suggested() -> dict[str, Any]:
"""IPs détectées non encore classifiées, triées par sévérité."""
try:
_ensure_feedback_table()
rows = query(
f"SELECT toString(d.src_ip) AS src_ip, "
f"max(d.anomaly_score) AS worst_score, "
f"max(d.threat_level) AS threat_level, "
f"count() AS detection_count, "
f"any(d.ja4) AS ja4, any(d.host) AS host, "
f"any(d.asn_org) AS asn_org, any(d.country_code) AS country_code "
f"FROM {_DB}.ml_detected_anomalies AS d "
f"LEFT JOIN {_DB}.soc_feedback AS f "
f"ON d.src_ip = f.src_ip "
"WHERE d.detected_at >= now() - INTERVAL 3 DAY "
"AND f.src_ip IS NULL "
"GROUP BY d.src_ip "
"ORDER BY worst_score DESC "
"LIMIT 20"
)
return {"suggested": rows}
except Exception as exc:
logger.exception("classify suggested query failed")
return {"suggested": []}
# ---------------------------------------------------------------------------
# POST /api/classify — SOC analyst feedback
# ---------------------------------------------------------------------------

View File

@ -5,36 +5,96 @@
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Feedback analyste SOC</h4>
<p>Classifiez les IPs pour entraîner le modèle XGBoost supervisé. Les labels sont utilisés au prochain cycle ML.</p>
<p><strong>Bot :</strong> Confirme que l'IP est malveillante. <strong>Légitime :</strong> Faux positif. <strong>Suspect :</strong> À surveiller.</p>
<p><strong>Workflow :</strong> 1. Consultez les IPs suggérées (non classifiées). 2. Classifiez-les. 3. Les labels alimentent XGBoost au prochain cycle.</p>
<p><strong>Bot :</strong> Confirme une IP malveillante. <strong>Légitime :</strong> Faux positif. <strong>Suspect :</strong> À surveiller.</p>
<p class="doc-source">Source : soc_feedback → XGBoost training</p>
</div></span>
{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="bg-gray-900 rounded-xl p-6 border border-gray-800 space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Adresse IP</label>
<input type="text" id="cls-ip" placeholder="ex: 192.168.1.100" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 focus:border-brand-500 focus:outline-none">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Classification</label>
<select id="cls-type" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300">
<option value="bot">🤖 Bot malveillant</option>
<option value="legitimate">✅ Trafic légitime</option>
<option value="suspicious">⚠️ Suspect (à surveiller)</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Commentaire</label>
<textarea id="cls-comment" rows="3" placeholder="Raison de la classification..." class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 focus:border-brand-500 focus:outline-none resize-none"></textarea>
</div>
<button id="cls-submit" class="px-6 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 transition-colors">Envoyer la classification</button>
<div id="cls-result" class="text-sm"></div>
<div class="space-y-4">
<!-- KPIs -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Total classifiées</div><div class="text-xl font-bold text-brand-500" id="kpi-total">0</div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">🤖 Bots confirmés</div><div class="text-xl font-bold text-red-400" id="kpi-bots">0</div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">✅ Légitimes</div><div class="text-xl font-bold text-green-400" id="kpi-legit">0</div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">⚠️ Suspects</div><div class="text-xl font-bold text-yellow-400" id="kpi-suspect">0</div></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Classification form -->
<div class="section-card">
<div class="section-header"><span class="section-title">Nouvelle classification
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Classifier une IP</h4>
<p>Saisissez une IP ou cliquez sur une suggestion. La classification est immédiatement enregistrée et sera utilisée par XGBoost au prochain cycle.</p>
<p class="doc-source">Table : soc_feedback</p>
</div></span>
</span></div>
<div class="section-body space-y-3">
<div>
<label class="block text-[11px] text-gray-500 mb-1">Adresse IP</label>
<input type="text" id="cls-ip" placeholder="ex: 192.168.1.100" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 focus:border-brand-500 focus:outline-none">
</div>
<div>
<label class="block text-[11px] text-gray-500 mb-1">Classification</label>
<div class="grid grid-cols-3 gap-2">
<button class="cls-type-btn px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30" data-cls="bot">🤖 Bot</button>
<button class="cls-type-btn px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30" data-cls="legitimate">✅ Légitime</button>
<button class="cls-type-btn px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 hover:bg-yellow-500/30" data-cls="suspicious">⚠️ Suspect</button>
</div>
</div>
<div>
<label class="block text-[11px] text-gray-500 mb-1">Commentaire (optionnel)</label>
<textarea id="cls-comment" rows="2" placeholder="Raison de la classification..." class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 focus:border-brand-500 focus:outline-none resize-none"></textarea>
</div>
<button id="cls-submit" class="w-full px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-30" disabled>Sélectionnez un type ci-dessus</button>
<div id="cls-result" class="text-sm"></div>
</div>
</div>
<!-- Suggested IPs + Distribution chart -->
<div class="lg:col-span-2 space-y-4">
<!-- Suggested IPs -->
<div class="section-card overflow-hidden">
<div class="section-header"><span class="section-title">IPs suggérées (non classifiées)
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Suggestions de classification</h4>
<p>IPs détectées comme anomalies dans les 3 derniers jours qui n'ont pas encore de label SOC. Triées par score descendant.</p>
<p><strong>Action :</strong> Cliquez sur une IP pour la pré-remplir dans le formulaire, ou utilisez les boutons rapides.</p>
<p class="doc-source">Source : ml_detected_anomalies LEFT JOIN soc_feedback</p>
</div></span>
</span></div>
<div class="overflow-x-auto" style="max-height:35vh; overflow-y:auto">
<table class="data-table"><thead><tr>
<th>IP</th><th>Score max</th><th>Menace</th><th>Détections</th><th>JA4</th><th>ASN</th><th>Pays</th><th>Action</th>
</tr></thead><tbody id="suggested-body"></tbody></table>
</div>
</div>
<!-- Distribution chart -->
<div class="section-card">
<div class="section-header"><span class="section-title">Répartition des classifications
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Distribution des labels</h4>
<p>Ratio bot/légitime/suspect dans les labels SOC. Un bon ratio aide XGBoost à apprendre. Visez ≥100 labels par catégorie.</p>
<p class="doc-source">Source : soc_feedback GROUP BY classification</p>
</div></span>
</span></div>
<div class="section-body"><div id="dist-chart" style="height:180px"></div></div>
</div>
</div>
</div>
<!-- Recent classifications -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Classifications récentes</h3>
<div class="overflow-x-auto">
<div class="section-card overflow-hidden">
<div class="section-header"><span class="section-title">Historique des classifications
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Classifications récentes</h4>
<p>Les 50 dernières classifications effectuées par les analystes SOC. Chaque label sera utilisé par XGBoost au prochain cycle ML.</p>
<p class="doc-source">Source : soc_feedback ORDER BY created_at DESC</p>
</div></span>
</span></div>
<div class="overflow-x-auto" style="max-height:40vh; overflow-y:auto">
<table class="data-table"><thead><tr>
<th>Date</th><th>IP</th><th>Classification</th><th>Commentaire</th>
</tr></thead><tbody id="cls-history"></tbody></table>
@ -44,30 +104,111 @@
{% endblock %}
{% block scripts %}
<script>
let selectedCls = '';
// ── Classification type toggle ──
document.querySelectorAll('.cls-type-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.cls-type-btn').forEach(b => b.classList.remove('ring-2','ring-white'));
btn.classList.add('ring-2','ring-white');
selectedCls = btn.dataset.cls;
const sub = document.getElementById('cls-submit');
sub.disabled = false;
sub.textContent = {bot:'🤖 Classifier comme Bot',legitimate:'✅ Classifier comme Légitime',suspicious:'⚠️ Classifier comme Suspect'}[selectedCls];
};
});
// ── Submit ──
document.getElementById('cls-submit').onclick = async () => {
const ip = document.getElementById('cls-ip').value.trim();
if (!ip) { alert('Veuillez saisir une IP'); return; }
if (!selectedCls) { alert('Sélectionnez un type'); return; }
try {
const r = await fetch('/api/classify', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({src_ip:ip, classification:document.getElementById('cls-type').value, comment:document.getElementById('cls-comment').value})});
body:JSON.stringify({src_ip:ip, classification:selectedCls, comment:document.getElementById('cls-comment').value})});
const d = await r.json();
document.getElementById('cls-result').innerHTML = r.ok
? `<span class="text-green-400">✓ ${ip} classifié : ${d.classification}</span>`
: `<span class="text-red-400">✗ Erreur : ${d.detail||'unknown'}</span>`;
if (r.ok) loadHistory();
? `<span class="text-green-400">✓ ${escapeHtml(ip)} ${d.classification}</span>`
: `<span class="text-red-400">✗ ${escapeHtml(d.detail||'erreur')}</span>`;
if (r.ok) { document.getElementById('cls-ip').value=''; document.getElementById('cls-comment').value=''; loadAll(); }
} catch(e) { document.getElementById('cls-result').innerHTML = `<span class="text-red-400">✗ ${e}</span>`; }
};
async function loadHistory() {
try {
const r = await fetch('/api/classifications'); const d = await r.json();
document.getElementById('cls-history').innerHTML = (d.data||[]).map(row => `<tr>
<td class="text-xs">${row.created_at||''}</td>
<td>${fmtIP(row.src_ip)}</td>
<td><span class="badge ${row.classification==='bot'?'badge-critical':row.classification==='legitimate'?'badge-low':'badge-medium'}">${row.classification}</span></td>
<td class="text-xs max-w-[300px] truncate">${row.comment||''}</td>
</tr>`).join('') || '<tr><td colspan="4" class="text-center text-gray-500 py-4">Aucune classification</td></tr>';
} catch(e) {}
function prefillIP(ip) {
document.getElementById('cls-ip').value = ip;
document.getElementById('cls-ip').scrollIntoView({behavior:'smooth',block:'center'});
document.getElementById('cls-ip').focus();
}
loadHistory();
function quickClassify(ip, cls) {
document.getElementById('cls-ip').value = ip;
selectedCls = cls;
document.getElementById('cls-submit').click();
}
async function loadAll() {
try {
const [stats, suggested, history] = await Promise.all([
fetch('/api/classify/stats').then(r=>r.json()),
fetch('/api/classify/suggested').then(r=>r.json()),
fetch('/api/classifications').then(r=>r.json()),
]);
// ── KPIs ──
const byType = {};
(stats.stats||[]).forEach(r => { byType[r.classification] = r.cnt; });
document.getElementById('kpi-total').textContent = fmtNum(stats.total||0);
document.getElementById('kpi-bots').textContent = fmtNum(byType.bot||0);
document.getElementById('kpi-legit').textContent = fmtNum(byType.legitimate||0);
document.getElementById('kpi-suspect').textContent = fmtNum(byType.suspicious||0);
// ── Distribution chart ──
const CLS_COLORS = {bot:'#ef4444',legitimate:'#22c55e',suspicious:'#eab308'};
const CLS_LABELS = {bot:'🤖 Bot',legitimate:'✅ Légitime',suspicious:'⚠️ Suspect'};
if (stats.total > 0) {
const el = document.getElementById('dist-chart');
const ch = echarts.init(el);
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'item'}),
series: [{type:'pie', radius:['35%','65%'], center:['50%','50%'],
label:{color:EC_TEXT,fontSize:11,formatter:'{b}\n{d}%'},
data:(stats.stats||[]).map(r=>({name:CLS_LABELS[r.classification]||r.classification,value:r.cnt,itemStyle:{color:CLS_COLORS[r.classification]||'#6b7280'}}))}]
}));
} else {
document.getElementById('dist-chart').innerHTML = '<div class="flex items-center justify-center h-full text-gray-500 text-sm">Aucune classification — commencez à labéliser !</div>';
}
// ── Suggested IPs ──
document.getElementById('suggested-body').innerHTML = (suggested.suggested||[]).map(row => `<tr>
<td class="whitespace-nowrap cursor-pointer hover:text-brand-400" onclick="prefillIP('${escapeHtml(row.src_ip)}')">${fmtIP(row.src_ip)}</td>
<td>${fmtScore(row.worst_score)}</td>
<td>${threatBadge(row.threat_level)}</td>
<td class="font-mono text-xs">${row.detection_count||0}</td>
<td class="text-xs font-mono max-w-[80px] truncate">${fmtJA4(row.ja4)}</td>
<td class="text-xs max-w-[100px] truncate">${row.asn_org ? fmtASN(row.asn_org) : ''}</td>
<td>${fmtCountry(row.country_code)}</td>
<td class="whitespace-nowrap">
<button onclick="quickClassify('${escapeHtml(row.src_ip)}','bot')" class="px-1.5 py-0.5 text-[10px] bg-red-500/20 text-red-400 rounded hover:bg-red-500/30" title="Bot">🤖</button>
<button onclick="quickClassify('${escapeHtml(row.src_ip)}','legitimate')" class="px-1.5 py-0.5 text-[10px] bg-green-500/20 text-green-400 rounded hover:bg-green-500/30" title="Légitime">✅</button>
<a href="/ip/${encodeURIComponent(row.src_ip)}" class="px-1.5 py-0.5 text-[10px] bg-gray-700 text-gray-300 rounded hover:bg-gray-600 inline-block" title="Détail">🔍</a>
</td>
</tr>`).join('') || '<tr><td colspan="8" class="text-center text-gray-500 py-4">Toutes les IPs ont été classifiées 🎉</td></tr>';
// ── History ──
document.getElementById('cls-history').innerHTML = (history.data||[]).map(row => `<tr onclick="window.location='/ip/${encodeURIComponent(row.src_ip)}'">
<td class="text-xs text-gray-400">${(row.created_at||'').substring(0,16)}</td>
<td class="whitespace-nowrap">${fmtIP(row.src_ip)}</td>
<td><span class="badge ${row.classification==='bot'?'badge-critical':row.classification==='legitimate'?'badge-low':'badge-medium'}">${escapeHtml(row.classification)}</span></td>
<td class="text-xs max-w-[300px] truncate text-gray-400">${escapeHtml(row.comment||'')}</td>
</tr>`).join('') || '<tr><td colspan="4" class="text-center text-gray-500 py-4">Aucune classification</td></tr>';
} catch(e) { console.error('classify load error:', e); }
}
// ── Pre-fill from URL ──
const urlIP = new URLSearchParams(window.location.search).get('ip');
if (urlIP) document.getElementById('cls-ip').value = urlIP;
loadAll();
</script>
{% endblock %}

View File

@ -14,15 +14,33 @@
<!-- Summary charts -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div class="section-card">
<div class="section-header"><span class="section-title">Par threat level</span></div>
<div class="section-header"><span class="section-title">Par threat level
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Répartition des menaces</h4>
<p>CRITICAL = score très élevé + multi-signal. HIGH = score au-dessus du seuil. KNOWN_BOT = identifié par dictionnaire. Cliquez sur un segment pour filtrer.</p>
<p class="doc-source">Source : ml_detected_anomalies</p>
</div></span>
</span></div>
<div class="p-3"><div id="det-threat-chart" style="height:140px"></div></div>
</div>
<div class="section-card">
<div class="section-header"><span class="section-title">Top raisons</span></div>
<div class="section-header"><span class="section-title">Top raisons
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Raisons de détection</h4>
<p>Motifs de déclenchement : score IF élevé, bot connu, Anubis DENY, etc. Aide à comprendre pourquoi une IP est détectée.</p>
<p class="doc-source">Source : ml_detected_anomalies.reason</p>
</div></span>
</span></div>
<div class="p-3"><div id="det-reason-chart" style="height:140px"></div></div>
</div>
<div class="section-card">
<div class="section-header"><span class="section-title">Top ASN détectés</span></div>
<div class="section-header"><span class="section-title">Top ASN détectés
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>ASN des détections</h4>
<p>Autonomous Systems d'où proviennent les menaces. Les hébergeurs (OVH, Hetzner, DigitalOcean) sont souvent en tête car utilisés par les botnets.</p>
<p class="doc-source">Source : ml_detected_anomalies.asn_org</p>
</div></span>
</span></div>
<div class="p-3"><div id="det-asn-chart" style="height:140px"></div></div>
</div>
</div>

View File

@ -14,20 +14,41 @@
<!-- Row 1: Radar + Feature Importance -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Profil Humain vs Bot (Radar)</h3>
<div id="chart-radar" style="height:360px"></div>
<div class="section-card">
<div class="section-header"><span class="section-title">Profil Humain vs Bot (Radar)
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Comparaison ISP vs Datacenter</h4>
<p>Profil moyen des sessions ISP (humaines) vs sessions datacenter (bots potentiels). Les axes sont les features ML normalisées.</p>
<p><strong>Interprétation :</strong> Plus la zone rouge dépasse la verte, plus la feature est discriminante. hit_velocity, fuzzing_index et post_ratio sont typiquement les plus discriminants.</p>
<p class="doc-source">Source : view_ai_features_1h GROUP BY asn_label</p>
</div></span>
</span></div>
<div class="section-body"><div id="chart-radar" style="height:360px"></div></div>
</div>
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Importance des features (Variance)</h3>
<div id="chart-importance" style="height:360px"></div>
<div class="section-card">
<div class="section-header"><span class="section-title">Importance des features (Variance)
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Feature importance</h4>
<p>Variance inter-classe (ISP vs datacenter) de chaque feature. Les features à haute variance discriminent le mieux bots et humains.</p>
<p><strong>Usage :</strong> Les features en tête sont les plus utiles pour le modèle EIF. Celles à variance nulle sont élaguées automatiquement.</p>
<p class="doc-source">Source : view_ai_features_1h</p>
</div></span>
</span></div>
<div class="section-body"><div id="chart-importance" style="height:360px"></div></div>
</div>
</div>
<!-- Row 2: Scatter full-width -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Scatter — Hit Velocity vs Fuzzing Index</h3>
<div id="chart-scatter" style="height:420px"></div>
<div class="section-card">
<div class="section-header"><span class="section-title">Scatter — Hit Velocity vs Fuzzing Index
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Scatter bidimensionnel</h4>
<p>Chaque point = une session IP. X = cadence de requêtes, Y = diversité des paths. Les clusters séparés du groupe principal sont des anomalies.</p>
<p><strong>Action :</strong> Cliquez sur un point pour ouvrir la page IP détail.</p>
<p class="doc-source">Source : view_ai_features_1h</p>
</div></span>
</span></div>
<div class="section-body"><div id="chart-scatter" style="height:420px"></div></div>
</div>
<!-- Row 3: Distribution histograms (3-col grid) -->

View File

@ -91,10 +91,27 @@
</span></div>
<div class="overflow-x-auto" style="max-height:35vh; overflow-y:auto">
<table class="data-table"><thead><tr>
<th>Time</th><th>Method</th><th>Host</th><th>Path</th><th>HTTP</th><th>User-Agent</th><th>JA4</th>
<th>Time</th><th>Method</th><th>Status</th><th>Host</th><th>Path</th><th>HTTP</th><th>User-Agent</th><th>JA4</th>
</tr></thead><tbody id="http-body"></tbody></table>
</div>
</div>
<!-- Resource cascade -->
<div class="section-card overflow-hidden" id="cascade-section" style="display:none">
<div class="section-header"><span class="section-title">Cascade de ressources
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Analyse de cascade</h4>
<p>Détection de navigateurs headless : un vrai navigateur charge une page HTML puis ses sous-ressources (CSS, JS, images) avec un délai croissant. Un bot ne charge souvent que la page principale.</p>
<p><strong>Indicateurs :</strong> page_count=1 + max_sub=0 = bot probable. avg_sub_delay très bas = headless rapide.</p>
<p class="doc-source">Source : view_resource_cascade_1h</p>
</div></span>
</span></div>
<div class="overflow-x-auto" style="max-height:25vh; overflow-y:auto">
<table class="data-table"><thead><tr>
<th>Fenêtre</th><th>Host</th><th>Pages</th><th>Sub-resources max</th><th>Délai moyen (ms)</th><th>Écart-type (ms)</th>
</tr></thead><tbody id="cascade-body"></tbody></table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
@ -111,9 +128,10 @@ function initChart(id) {
async function loadIP() {
try {
const [d, radar] = await Promise.all([
const [d, radar, cascade] = await Promise.all([
fetch(`/api/ip/${encodeURIComponent(IP)}`).then(r=>r.json()),
fetch(`/api/ip/${encodeURIComponent(IP)}/radar`).then(r=>r.json()),
fetch(`/api/cascade/${encodeURIComponent(IP)}`).then(r=>r.json()),
]);
// KPIs
@ -209,15 +227,31 @@ async function loadIP() {
}
// HTTP logs
const sc = s => s>=500?'text-red-400':s>=400?'text-orange-400':s>=300?'text-yellow-400':'text-green-400';
document.getElementById('http-body').innerHTML = (d.http_logs||[]).map(row => `<tr>
<td class="text-[11px] whitespace-nowrap text-gray-400">${row.time||''}</td>
<td><span class="font-mono text-xs ${row.method==='POST'?'text-orange-400':'text-gray-300'}">${escapeHtml(row.method||'')}</span></td>
<td class="${sc(row.status||0)} font-mono text-[11px]">${row.status||''}</td>
<td class="text-xs max-w-[100px] truncate">${escapeHtml(row.host||'')}</td>
<td class="text-xs max-w-[200px] truncate font-mono" title="${escapeHtml(row.path||'')}">${escapeHtml(row.path||'')}</td>
<td class="font-mono text-[11px] text-gray-400">${escapeHtml(row.http_version||'')}</td>
<td class="text-xs max-w-[180px] truncate text-gray-400">${escapeHtml(row.header_user_agent||'')}</td>
<td class="font-mono text-[11px]">${escapeHtml(row.ja4||'')}</td>
</tr>`).join('') || '<tr><td colspan="7" class="text-center text-gray-500 py-4">Aucun log</td></tr>';
</tr>`).join('') || '<tr><td colspan="8" class="text-center text-gray-500 py-4">Aucun log</td></tr>';
// Cascade
const cascadeRows = cascade.data || [];
if (cascadeRows.length) {
document.getElementById('cascade-section').style.display = '';
document.getElementById('cascade-body').innerHTML = cascadeRows.map(row => `<tr>
<td class="text-[11px] whitespace-nowrap text-gray-400">${(row.window_start||'').substring(0,16)}</td>
<td class="text-xs">${escapeHtml(row.host||'')}</td>
<td class="font-mono text-xs">${row.page_count||0}</td>
<td class="font-mono text-xs">${row.max_sub_resources||0}</td>
<td class="font-mono text-xs ${(row.avg_sub_delay_ms||0)<50?'text-red-400':'text-gray-300'}">${(row.avg_sub_delay_ms||0).toFixed(0)}</td>
<td class="font-mono text-xs">${(row.stddev_sub_delay_ms||0).toFixed(0)}</td>
</tr>`).join('');
}
} catch(e) { console.error(e); }
}

View File

@ -3,27 +3,90 @@
{% block page_title %}
Modèles ML
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>État des modèles ML</h4>
<h4>Monitoring des modèles ML</h4>
<p>Ensemble triple-voix : Extended Isolation Forest (EIF) + Autoencoder (AE) + XGBoost supervisé.</p>
<p><strong>Versions :</strong> Chaque cycle crée un nouveau modèle si une dérive est détectée (95% features). Les anciens modèles restent en cache.</p>
<p class="doc-source">Source : /data/models/*.json, ml_all_scores</p>
<p><strong>Cycle :</strong> Toutes les 30 min, le bot-detector ré-entraîne si une dérive est détectée (95% features). Les anciens modèles restent en cache.</p>
<p><strong>Workflow :</strong> Surveillez le volume de scoring, le taux d'anomalie et la santé des modèles. Un taux d'anomalie > 10% peut indiquer une attaque ou un modèle dégradé.</p>
<p class="doc-source">Source : ml_all_scores (7j), /data/models/*.json</p>
</div></span>
{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Scoring stats from ClickHouse -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Statistiques de scoring (7 derniers jours)</h3>
<div class="space-y-4">
<!-- KPIs -->
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Sessions scorées (7j)</div><div class="text-xl font-bold text-brand-500" id="kpi-total"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Modèles actifs</div><div class="text-xl font-bold text-green-400" id="kpi-models"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Anomalies détectées</div><div class="text-xl font-bold text-red-400" id="kpi-anomalies"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Taux d'anomalie</div><div class="text-xl font-bold text-orange-400" id="kpi-rate"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Dernier scoring</div><div class="text-sm font-medium text-gray-200" id="kpi-last"></div></div>
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Dernier entraînement</div><div class="text-sm font-medium text-yellow-400" id="kpi-train"></div></div>
</div>
<!-- Scoring volume timeline + Threat breakdown -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="section-card lg:col-span-2">
<div class="section-header"><span class="section-title">Volume de scoring
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Timeline de scoring</h4>
<p>Nombre de sessions scorées par heure et par modèle. La courbe orange montre le score moyen d'anomalie.</p>
<p><strong>Interprétation :</strong> Un creux soudain indique un problème de pipeline. Un pic de score moyen = vague d'attaque.</p>
<p class="doc-source">Source : ml_all_scores GROUP BY hour, model_name</p>
</div></span>
</span></div>
<div class="section-body"><div id="timeline-chart" style="height:260px"></div></div>
</div>
<div class="section-card">
<div class="section-header"><span class="section-title">Répartition menaces
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Menaces par modèle</h4>
<p>Répartition des niveaux de menace (NORMAL, HIGH, CRITICAL, KNOWN_BOT, LEGITIMATE_BROWSER) par modèle.</p>
<p class="doc-source">Source : ml_all_scores GROUP BY model_name, threat_level</p>
</div></span>
</span></div>
<div class="section-body"><div id="threats-chart" style="height:260px"></div></div>
</div>
</div>
<!-- Score avg timeline (anomaly rate) -->
<div class="section-card">
<div class="section-header"><span class="section-title">Taux d'anomalie horaire
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Anomaly rate over time</h4>
<p>Pourcentage de sessions classées HIGH/CRITICAL par heure. Les barres montrent le volume, la ligne le taux.</p>
<p><strong>Seuil d'alerte :</strong> Un taux > 10% prolongé mérite investigation.</p>
<p class="doc-source">Source : ml_all_scores</p>
</div></span>
</span></div>
<div class="section-body"><div id="rate-chart" style="height:200px"></div></div>
</div>
<!-- Scoring stats table -->
<div class="section-card overflow-hidden">
<div class="section-header"><span class="section-title">Statistiques de scoring (7 jours)
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Résumé par modèle</h4>
<p>Sessions scorées, période active et dernière activité pour chaque modèle. Complet = L3→L7 corrélé, Applicatif = L7 seul.</p>
<p class="doc-source">Source : ml_all_scores GROUP BY model_name</p>
</div></span>
</span></div>
<div class="overflow-x-auto">
<table class="data-table"><thead><tr>
<th>Modèle</th><th>Sessions scorées</th><th>Premier scoring</th><th>Dernier scoring</th>
</tr></thead><tbody id="scoring-body"></tbody></table>
</div>
</div>
<!-- Model metadata files -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Métadonnées des modèles</h3>
<div id="model-cards" class="p-5 space-y-4">
<!-- Model metadata cards -->
<div class="section-card overflow-hidden">
<div class="section-header"><span class="section-title">Versions des modèles
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Métadonnées des modèles</h4>
<p>Chaque fichier .json dans /data/models/ décrit un modèle entraîné : version, algorithme, paramètres, métriques de validation.</p>
<p><strong>Gate de validation :</strong> Un modèle n'est utilisé que si val_anomaly_rate &lt; 5% et val_mean_score est raisonnable.</p>
<p class="doc-source">Source : /data/models/*.json</p>
</div></span>
</span></div>
<div id="model-cards" class="p-5 grid grid-cols-1 lg:grid-cols-2 gap-4">
<span class="text-sm text-gray-500">Chargement...</span>
</div>
</div>
@ -31,43 +94,173 @@
{% endblock %}
{% block scripts %}
<script>
let mCharts = {};
function initC(id) { const el=document.getElementById(id); if(!el) return null; if(mCharts[id]) mCharts[id].dispose(); mCharts[id]=echarts.init(el); return mCharts[id]; }
async function loadModels() {
try {
const r = await fetch('/api/models'); const d = await r.json();
// Scoring stats table
document.getElementById('scoring-body').innerHTML = (d.scoring_stats||[]).map(row => `<tr>
<td class="font-medium text-gray-200">${row.model_name||''}</td>
<td>${(row.scored||0).toLocaleString()}</td>
<td class="text-xs">${row.first_seen||''}</td>
<td class="text-xs">${row.last_seen||''}</td>
</tr>`).join('') || '<tr><td colspan="4" class="text-center text-gray-500 py-4">Aucun scoring récent</td></tr>';
// Model metadata cards
const cards = document.getElementById('model-cards');
if (d.models?.length) {
cards.innerHTML = d.models.map(m => `
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm font-semibold text-white">${m.model_name||'?'} v${m.version_id||'?'}</span>
<span class="badge badge-low">${m.algorithm||'?'}</span>
${m.autoencoder ? '<span class="badge badge-medium">+AE</span>' : ''}
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-gray-400">
<div>Entraîné : <span class="text-gray-300">${m.trained_at||'?'}</span></div>
<div>Échantillons : <span class="text-gray-300">${m.human_samples||'?'}</span></div>
<div>Contamination : <span class="text-gray-300">${m.contamination||'?'}</span></div>
<div>Seuil : <span class="text-gray-300">${m.threshold||'?'}</span></div>
${m.validation ? `<div>Val anomaly rate : <span class="text-gray-300">${(m.validation.val_anomaly_rate*100).toFixed(1)}%</span></div>
<div>Val mean score : <span class="text-gray-300">${m.validation.val_mean_score?.toFixed(4)||'?'}</span></div>
<div>Train size : <span class="text-gray-300">${m.validation.train_size||'?'}</span></div>
<div>Val size : <span class="text-gray-300">${m.validation.val_size||'?'}</span></div>` : ''}
</div>
</div>
`).join('');
} else {
cards.innerHTML = '<span class="text-sm text-gray-500">Aucun fichier de métadonnées trouvé (les modèles sont dans /data/models/)</span>';
const [md, tl, th] = await Promise.all([
fetch('/api/models').then(r=>r.json()),
fetch('/api/models/timeline').then(r=>r.json()),
fetch('/api/models/threats').then(r=>r.json()),
]);
// ── KPIs ──
const stats = md.scoring_stats || [];
const totalScored = stats.reduce((s,r)=>s+(r.scored||0),0);
document.getElementById('kpi-total').textContent = fmtNum(totalScored);
document.getElementById('kpi-models').textContent = stats.length;
const lastSeen = stats.map(r=>r.last_seen||'').sort().pop() || '—';
document.getElementById('kpi-last').textContent = lastSeen.substring(0,16);
if (md.models?.length) {
const latest = md.models[md.models.length-1];
document.getElementById('kpi-train').textContent = (latest.trained_at||'').substring(0,16);
}
} catch(e) { console.error(e); }
// ── Timeline chart ──
const tlRows = tl.timeline || [];
if (tlRows.length) {
const hours = [...new Set(tlRows.map(r=>r.hour))].sort();
const models = [...new Set(tlRows.map(r=>r.model_name))];
const MODEL_COLORS = {'Complet':'#6366f1','Applicatif':'#14b8a6','Complet_24h':'#8b5cf6','Applicatif_24h':'#06b6d4'};
const byHourModel = {};
let totalAnom = 0;
tlRows.forEach(r => { byHourModel[r.hour+'|'+r.model_name] = r; totalAnom += (r.anomalies||0); });
document.getElementById('kpi-anomalies').textContent = fmtNum(totalAnom);
document.getElementById('kpi-rate').textContent = totalScored>0 ? (totalAnom/totalScored*100).toFixed(1)+'%' : '—';
const ch = initC('timeline-chart');
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'axis'}),
legend: {data:models, bottom:0, textStyle:{color:EC_TEXT,fontSize:10}},
grid: {left:50,right:20,top:15,bottom:35},
xAxis: {type:'category', data:hours.map(h=>(h||'').substring(11,16)), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT,fontSize:10}},
yAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
series: models.map(m => ({
name:m, type:'bar', stack:'vol', barWidth:'70%',
data: hours.map(h => (byHourModel[h+'|'+m]||{}).cnt || 0),
itemStyle:{color:MODEL_COLORS[m]||'#6b7280'},
}))
}));
}
// ── Anomaly rate chart ──
if (tlRows.length) {
const hours = [...new Set(tlRows.map(r=>r.hour))].sort();
const hourAgg = {};
tlRows.forEach(r => {
if (!hourAgg[r.hour]) hourAgg[r.hour] = {total:0, anom:0};
hourAgg[r.hour].total += r.cnt||0;
hourAgg[r.hour].anom += r.anomalies||0;
});
const ch = initC('rate-chart');
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'axis'}),
grid: {left:50,right:50,top:15,bottom:25},
xAxis: {type:'category', data:hours.map(h=>(h||'').substring(11,16)), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT,fontSize:10}},
yAxis: [
{type:'value', name:'Sessions', nameTextStyle:{color:EC_TEXT}, splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
{type:'value', name:'Taux %', nameTextStyle:{color:EC_TEXT}, max:100, splitLine:{show:false}, axisLabel:{color:EC_TEXT,formatter:'{value}%'}},
],
series: [
{name:'Sessions', type:'bar', yAxisIndex:0, barWidth:'60%',
data:hours.map(h=>(hourAgg[h]||{}).total||0),
itemStyle:{color:'rgba(99,102,241,0.3)'}},
{name:'Taux anomalie', type:'line', yAxisIndex:1, smooth:true,
data:hours.map(h=>{const a=hourAgg[h]; return a&&a.total>0 ? +(a.anom/a.total*100).toFixed(1) : 0;}),
lineStyle:{color:'#f97316',width:2}, itemStyle:{color:'#f97316'}, symbol:'circle', symbolSize:3,
areaStyle:{color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:'rgba(249,115,22,0.15)'},{offset:1,color:'rgba(249,115,22,0)'}])}},
]
}));
}
// ── Threats chart ──
const thRows = th.threats || [];
if (thRows.length) {
const models = [...new Set(thRows.map(r=>r.model_name))];
const levels = [...new Set(thRows.map(r=>r.threat_level))];
const TCOLS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',LEGITIMATE_BROWSER:'#22c55e',ANUBIS_DENY:'#a855f7'};
const byMT = {};
thRows.forEach(r => { byMT[r.model_name+'|'+r.threat_level] = r.cnt; });
const ch = initC('threats-chart');
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'axis'}),
legend: {data:levels, bottom:0, textStyle:{color:EC_TEXT,fontSize:9}},
grid: {left:15,right:15,top:15,bottom:50},
xAxis: {type:'category', data:models, axisLabel:{color:EC_TEXT,fontSize:10}, axisLine:{lineStyle:{color:EC_GRID}}},
yAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
series: levels.map(l => ({
name:l, type:'bar', stack:'t', barWidth:'50%',
data:models.map(m => byMT[m+'|'+l]||0),
itemStyle:{color:TCOLS[l]||'#6b7280'},
}))
}));
}
// ── Scoring stats table ──
document.getElementById('scoring-body').innerHTML = stats.map(row => `<tr>
<td class="font-medium text-gray-200">${escapeHtml(row.model_name||'')}</td>
<td class="font-mono">${fmtNum(row.scored||0)}</td>
<td class="text-xs text-gray-400">${(row.first_seen||'').substring(0,16)}</td>
<td class="text-xs text-gray-400">${(row.last_seen||'').substring(0,16)}</td>
</tr>`).join('') || '<tr><td colspan="4" class="text-center text-gray-500 py-4">Aucun scoring récent</td></tr>';
// ── Model cards ──
const cards = document.getElementById('model-cards');
if (md.models?.length) {
cards.innerHTML = md.models.map(m => {
const valRate = m.validation?.val_anomaly_rate;
const valBadge = valRate != null
? (valRate > 0.05 ? 'badge-high' : valRate > 0.02 ? 'badge-medium' : 'badge-low')
: 'badge-normal';
return `
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors">
<div class="flex items-center gap-3 mb-3">
<span class="text-sm font-semibold text-white">${escapeHtml(m.model_name||'?')}</span>
<span class="badge badge-low text-[10px]">${escapeHtml(m.algorithm||'?')}</span>
${m.autoencoder ? '<span class="badge badge-medium text-[10px]">+AE</span>' : ''}
<span class="text-[10px] text-gray-500 ml-auto">v${escapeHtml(m.version_id||'?')}</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div class="bg-gray-900/50 rounded p-2">
<div class="text-[9px] text-gray-500 mb-0.5">Entraîné</div>
<div class="text-gray-300 font-mono">${(m.trained_at||'?').substring(0,16)}</div>
</div>
<div class="bg-gray-900/50 rounded p-2">
<div class="text-[9px] text-gray-500 mb-0.5">Échantillons</div>
<div class="text-gray-300 font-mono">${fmtNum(m.human_samples||0)}</div>
</div>
<div class="bg-gray-900/50 rounded p-2">
<div class="text-[9px] text-gray-500 mb-0.5">Contamination</div>
<div class="text-gray-300 font-mono">${m.contamination||'?'}</div>
</div>
<div class="bg-gray-900/50 rounded p-2">
<div class="text-[9px] text-gray-500 mb-0.5">Seuil</div>
<div class="text-gray-300 font-mono">${typeof m.threshold==='number'?m.threshold.toFixed(4):m.threshold||'?'}</div>
</div>
</div>
${m.validation ? `
<div class="mt-3 pt-3 border-t border-gray-700">
<div class="text-[10px] text-gray-500 mb-2 font-medium">Validation Gate</div>
<div class="grid grid-cols-4 gap-2 text-xs">
<div><span class="text-gray-500">Taux anomalie :</span> <span class="badge ${valBadge} text-[10px]">${(valRate*100).toFixed(1)}%</span></div>
<div><span class="text-gray-500">Score moyen :</span> <span class="text-gray-300">${m.validation.val_mean_score?.toFixed(4)||'?'}</span></div>
<div><span class="text-gray-500">Train :</span> <span class="text-gray-300">${fmtNum(m.validation.train_size||0)}</span></div>
<div><span class="text-gray-500">Val :</span> <span class="text-gray-300">${fmtNum(m.validation.val_size||0)}</span></div>
</div>
</div>` : ''}
${m.features_used ? `
<div class="mt-2 text-[10px] text-gray-500">${m.features_used} features · ${m.features_pruned||0} élaguées</div>` : ''}
</div>`;
}).join('');
} else {
cards.innerHTML = '<span class="text-sm text-gray-500">Aucun fichier de métadonnées trouvé</span>';
}
} catch(e) { console.error('models load error:', e); }
}
loadModels();
setInterval(loadModels, 60000);
window.addEventListener('resize', () => Object.values(mCharts).forEach(c=>c?.resize()));
</script>
{% endblock %}

View File

@ -14,13 +14,27 @@
<div class="space-y-4">
<!-- Score distribution charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 class="text-xs font-medium text-gray-500 mb-2">Distribution des scores d'anomalie</h3>
<div id="score-dist-chart" style="height:180px"></div>
<div class="section-card">
<div class="section-header"><span class="section-title">Distribution des scores
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Histogramme des scores d'anomalie</h4>
<p>Score normalisé [0,1] combinant EIF + AE + XGBoost. La majorité des sessions devraient être proches de 0 (normal). Un pic à droite = vague d'attaque.</p>
<p><strong>Seuil :</strong> Les sessions au-dessus du seuil (typiquement 0.50.7) sont classées HIGH/CRITICAL.</p>
<p class="doc-source">Source : ml_all_scores.anomaly_score</p>
</div></span>
</span></div>
<div class="section-body"><div id="score-dist-chart" style="height:180px"></div></div>
</div>
<div class="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 class="text-xs font-medium text-gray-500 mb-2">AE Error vs XGB Probability</h3>
<div id="score-scatter-chart" style="height:180px"></div>
<div class="section-card">
<div class="section-header"><span class="section-title">AE Error vs XGB Probability
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Scatter bi-modèle</h4>
<p>X = erreur de reconstruction autoencoder (comportement inhabituel). Y = probabilité XGBoost (supervisé sur labels SOC).</p>
<p><strong>Interprétation :</strong> Quadrant haut-droit = consensus des deux modèles. Haut-gauche = XGB dit bot mais AE dit normal → possible label biaisé.</p>
<p class="doc-source">Source : ml_all_scores.ae_recon_error, xgb_prob</p>
</div></span>
</span></div>
<div class="section-body"><div id="score-scatter-chart" style="height:180px"></div></div>
</div>
</div>
<div class="flex items-center gap-3 flex-wrap">
@ -30,7 +44,9 @@
<button class="filter-btn" data-filter="HIGH">High</button>
<button class="filter-btn" data-filter="NORMAL">Normal</button>
<button class="filter-btn" data-filter="KNOWN_BOT">Known Bot</button>
<button class="filter-btn" data-filter="LEGITIMATE_BROWSER">Browser</button>
</div>
<input type="text" id="search-input" placeholder="Rechercher IP, JA4, host…" class="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 w-64 focus:border-brand-500 focus:outline-none ml-auto">
</div>
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div class="overflow-x-auto max-h-[70vh] overflow-y-auto">
@ -76,11 +92,13 @@ async function loadScores() {
if(sASN) params.set('asn_org',sASN);
if(sCountry) params.set('country_code',sCountry);
if(sJA4) params.set('ja4',sJA4);
const sSearch = document.getElementById('search-input').value.trim();
if(sSearch) params.set('search',sSearch);
try {
const r = await fetch('/api/scores?'+params);
const d = await r.json();
const tbody = document.getElementById('scores-body');
tbody.innerHTML = (d.data||[]).map(row => `<tr>
tbody.innerHTML = (d.data||[]).map(row => `<tr onclick="window.location='/ip/'+encodeURIComponent('${escapeHtml(row.src_ip||'')}')">
<td class="text-xs whitespace-nowrap">${row.detected_at||''}</td>
<td class="whitespace-nowrap">${fmtIP(row.src_ip)}</td>
<td>${fmtScore(row.anomaly_score)}</td>
@ -88,9 +106,9 @@ async function loadScores() {
<td class="text-xs">${(row.ae_recon_error||0).toFixed(6)}</td>
<td class="text-xs">${(row.xgb_prob||0).toFixed(4)}</td>
<td>${fmtThreatLink(row.threat_level)}</td>
<td class="text-xs">${row.model_name||''}</td>
<td class="text-xs">${escapeHtml(row.model_name||'')}</td>
<td class="text-xs font-mono max-w-[100px] truncate">${fmtJA4(row.ja4)}</td>
<td class="text-xs max-w-[120px] truncate">${row.host||''}</td>
<td class="text-xs max-w-[120px] truncate">${escapeHtml(row.host||'')}</td>
<td>${row.hits||0}</td>
<td>${fmtCountry(row.country_code)}</td>
</tr>`).join('') || '<tr><td colspan="12" class="text-center text-gray-500 py-8">Aucun score</td></tr>';
@ -112,6 +130,12 @@ document.querySelectorAll('[data-filter]').forEach(btn => btn.onclick = () => {
btn.classList.add('active');
sThreat = btn.dataset.filter; sPage=1; loadScores();
});
// Search with debounce
let sSearchTimer;
document.getElementById('search-input').addEventListener('input', () => {
clearTimeout(sSearchTimer);
sSearchTimer = setTimeout(() => { sPage=1; loadScores(); }, 300);
});
loadScores();
// Score distribution charts

View File

@ -6,6 +6,7 @@
<h4>Logs HTTP bruts</h4>
<p>Toutes les requêtes HTTP capturées (24h). Filtrez par méthode, host ou status pour identifier les patterns suspects.</p>
<p><strong>Workflow :</strong> Filtrez POST → cherchez du brute-force → cliquez sur l'IP → investiguez.</p>
<p><strong>Codes couleur :</strong> GET=vert, POST=bleu, PUT=jaune, DELETE=rouge. Status : 2xx=vert, 3xx=jaune, 4xx=orange, 5xx=rouge.</p>
<p class="doc-source">Source : http_logs (24h)</p>
</div></span>
{% endblock %}
@ -13,17 +14,35 @@
<div class="space-y-4">
<!-- Traffic summary charts -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 class="text-xs font-medium text-gray-500 mb-2">Méthodes HTTP</h3>
<div id="method-chart" style="height:160px"></div>
<div class="section-card">
<div class="section-header"><span class="section-title">Méthodes HTTP
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Distribution des méthodes</h4>
<p>Ratio des méthodes HTTP. Un ratio POST anormalement élevé peut indiquer du brute-force ou du credential stuffing.</p>
<p class="doc-source">Source : http_logs (24h)</p>
</div></span>
</span></div>
<div class="section-body"><div id="method-chart" style="height:160px"></div></div>
</div>
<div class="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 class="text-xs font-medium text-gray-500 mb-2">Top 5 User-Agents</h3>
<div id="ua-chart" style="height:160px"></div>
<div class="section-card">
<div class="section-header"><span class="section-title">Top User-Agents
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>User-Agents les plus fréquents</h4>
<p>Les bots utilisent souvent des UAs génériques (python-requests, curl) ou vides. Un UA massivement représenté = potentiel botnet.</p>
<p class="doc-source">Source : http_logs (24h)</p>
</div></span>
</span></div>
<div class="section-body"><div id="ua-chart" style="height:160px"></div></div>
</div>
<div class="bg-gray-900 rounded-xl p-4 border border-gray-800">
<h3 class="text-xs font-medium text-gray-500 mb-2">Top 5 Paths</h3>
<div id="path-chart" style="height:160px"></div>
<div class="section-card">
<div class="section-header"><span class="section-title">Top Paths
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
<h4>Chemins les plus accédés</h4>
<p>Les paths comme /wp-admin, /xmlrpc.php, /.env indiquent du scanning. Un path API martelé = possible DDoS L7.</p>
<p class="doc-source">Source : http_logs (24h)</p>
</div></span>
</span></div>
<div class="section-body"><div id="path-chart" style="height:160px"></div></div>
</div>
</div>
<div class="flex items-center gap-3 flex-wrap">
@ -33,12 +52,14 @@
</select>
<input type="text" id="host-filter" placeholder="Filtrer host..." class="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 w-48 focus:border-brand-500 focus:outline-none">
<input type="number" id="status-filter" placeholder="Status" class="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 w-28 focus:border-brand-500 focus:outline-none">
<input type="text" id="search-filter" placeholder="Rechercher IP, path, UA…" class="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 w-64 focus:border-brand-500 focus:outline-none">
</div>
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div class="section-card overflow-hidden">
<div class="overflow-x-auto max-h-[70vh] overflow-y-auto">
<table class="data-table"><thead><tr>
<th>Time</th><th>IP</th><th>Method</th><th>Host</th><th>Path</th>
<th>HTTP Ver</th><th>User-Agent</th><th>JA4</th><th>Pays</th>
<th class="cursor-pointer" data-sort="time">Time ↕</th>
<th>IP</th><th>Method</th><th>Status</th><th>Host</th><th>Path</th>
<th>HTTP</th><th>User-Agent</th><th>JA4</th><th>Pays</th>
</tr></thead><tbody id="traffic-body"></tbody></table>
</div>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-800">
@ -53,40 +74,56 @@
{% endblock %}
{% block scripts %}
<script>
let tPage=1;
let tPage=1, tSort='time', tOrder='DESC';
const sc = s => s>=500?'text-red-400':s>=400?'text-orange-400':s>=300?'text-yellow-400':'text-green-400';
const mc = m => ({GET:'text-green-400',POST:'text-blue-400',PUT:'text-yellow-400',DELETE:'text-red-400'}[m]||'text-gray-400');
async function loadTraffic() {
const params = new URLSearchParams({page:tPage,per_page:100});
const params = new URLSearchParams({page:tPage,per_page:100,sort:tSort,order:tOrder});
const m=document.getElementById('method-filter').value;
const h=document.getElementById('host-filter').value;
const s=document.getElementById('status-filter').value;
if(m) params.set('method',m); if(h) params.set('host',h); if(s) params.set('status',s);
const q=document.getElementById('search-filter').value;
if(m) params.set('method',m); if(h) params.set('host',h); if(s) params.set('status',s); if(q) params.set('search',q);
try {
const r = await fetch('/api/traffic?'+params); const d = await r.json();
const tbody = document.getElementById('traffic-body');
const sc = s => s>=500?'text-red-400':s>=400?'text-orange-400':s>=300?'text-yellow-400':'text-green-400';
const mc = m => ({GET:'text-green-400',POST:'text-blue-400',PUT:'text-yellow-400',DELETE:'text-red-400'}[m]||'text-gray-400');
tbody.innerHTML = (d.data||[]).map(row => `<tr>
tbody.innerHTML = (d.data||[]).map(row => `<tr onclick="window.location='/ip/'+encodeURIComponent('${escapeHtml(row.src_ip||'')}')">
<td class="text-xs whitespace-nowrap">${row.time||''}</td>
<td class="whitespace-nowrap">${fmtIP(row.src_ip)}</td>
<td class="${mc(row.method)} font-mono text-xs">${row.method||''}</td>
<td class="text-xs max-w-[150px] truncate">${row.host||''}</td>
<td class="text-xs max-w-[250px] truncate font-mono" title="${row.path||''}">${row.path||''}</td>
<td class="font-mono text-xs">${row.http_version||''}</td>
<td class="text-xs max-w-[200px] truncate" title="${row.header_user_agent||''}">${row.header_user_agent||''}</td>
<td class="${sc(row.status||0)} font-mono text-xs">${row.status||''}</td>
<td class="text-xs max-w-[150px] truncate">${escapeHtml(row.host||'')}</td>
<td class="text-xs max-w-[250px] truncate font-mono" title="${escapeHtml(row.path||'')}">${escapeHtml(row.path||'')}</td>
<td class="font-mono text-xs">${escapeHtml(row.http_version||'')}</td>
<td class="text-xs max-w-[200px] truncate" title="${escapeHtml(row.header_user_agent||'')}">${escapeHtml(row.header_user_agent||'')}</td>
<td class="text-xs font-mono max-w-[100px] truncate">${fmtJA4(row.ja4)}</td>
<td>${fmtCountry(row.src_country_code)}</td>
</tr>`).join('') || '<tr><td colspan="9" class="text-center text-gray-500 py-8">Aucun log</td></tr>';
</tr>`).join('') || '<tr><td colspan="10" class="text-center text-gray-500 py-8">Aucun log</td></tr>';
const total=d.total||0;
document.getElementById('traffic-info').textContent=`${total} logs — page ${tPage}/${Math.max(1,Math.ceil(total/100))}`;
document.getElementById('traffic-info').textContent=`${fmtNum(total)} logs — page ${tPage}/${Math.max(1,Math.ceil(total/100))}`;
document.getElementById('prev-btn').disabled=tPage<=1;
document.getElementById('next-btn').disabled=tPage*100>=total;
} catch(e) { console.error(e); }
}
document.getElementById('prev-btn').onclick=()=>{if(tPage>1){tPage--;loadTraffic();}};
document.getElementById('next-btn').onclick=()=>{tPage++;loadTraffic();};
['method-filter','host-filter','status-filter'].forEach(id=>{
// Column sorting
document.querySelectorAll('[data-sort]').forEach(th => th.onclick = () => {
const s=th.dataset.sort;
if(tSort===s) tOrder=tOrder==='DESC'?'ASC':'DESC'; else {tSort=s;tOrder='DESC';}
tPage=1; loadTraffic();
});
// Filters with debounce
let filterTimer;
['method-filter','host-filter','status-filter','search-filter'].forEach(id=>{
let el=document.getElementById(id);
el.addEventListener(el.tagName==='SELECT'?'change':'input',()=>{tPage=1;loadTraffic();});
el.addEventListener(el.tagName==='SELECT'?'change':'input',()=>{
clearTimeout(filterTimer);
filterTimer=setTimeout(()=>{tPage=1;loadTraffic();},300);
});
});
loadTraffic();
@ -96,10 +133,7 @@ async function loadTrafficSummary() {
const r = await fetch('/api/traffic?per_page=500'); const d = await r.json();
const rows = d.data||[];
const METHOD_COLORS = {GET:'#22c55e',POST:'#3b82f6',PUT:'#eab308',DELETE:'#ef4444',HEAD:'#8b5cf6',OPTIONS:'#6b7280'};
// Method distribution
const methods = {};
const uas = {};
const paths = {};
const methods = {}, uas = {}, paths = {};
rows.forEach(row => {
methods[row.method] = (methods[row.method]||0)+1;
const ua = (row.header_user_agent||'').substring(0,30) || '(empty)';
@ -110,7 +144,6 @@ async function loadTrafficSummary() {
const ch1 = echarts.init(document.getElementById('method-chart'));
ch1.setOption(ecBase({tooltip:ecTooltip({trigger:'item'}),series:[{type:'pie',radius:['30%','65%'],label:{color:EC_TEXT,fontSize:10,formatter:'{b}\n{d}%'},
data:Object.entries(methods).map(([k,v])=>({name:k,value:v,itemStyle:{color:METHOD_COLORS[k]||'#6b7280'}}))}]}));
// Top UAs
const topUA = Object.entries(uas).sort((a,b)=>b[1]-a[1]).slice(0,5);
if (topUA.length) {
const ch2 = echarts.init(document.getElementById('ua-chart'));
@ -119,7 +152,6 @@ async function loadTrafficSummary() {
xAxis:{type:'value',show:false},
series:[{type:'bar',data:topUA.map(r=>r[1]).reverse(),barWidth:'60%',itemStyle:{color:'#3b82f6'},label:{show:true,position:'right',color:EC_TEXT,fontSize:10}}]}));
}
// Top paths
const topPath = Object.entries(paths).sort((a,b)=>b[1]-a[1]).slice(0,5);
if (topPath.length) {
const ch3 = echarts.init(document.getElementById('path-chart'));