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:
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) -->
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
|
||||
@ -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 < 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 %}
|
||||
|
||||
@ -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.5–0.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
|
||||
|
||||
@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user