feat(dashboard): rebuild SOC dashboard + fix ClickHouse SQL

Complete rewrite of the SOC dashboard using FastAPI + Jinja2 + htmx + Chart.js + Tailwind CSS.
Replaces the old React/Vite frontend with server-rendered templates.

Dashboard pages:
- Overview: KPIs, timeline chart, threat distribution, top IPs
- Detections: paginated/filterable anomaly table
- Scores: ml_all_scores with AE error & XGB prob columns
- Traffic: HTTP logs with method/host filters
- IP Investigation: full deep-dive (scores, features, HTTP logs, classify)
- Classification: SOC feedback form + history
- Features: AI + thesis feature stats
- Models: scoring stats + model metadata

API: 9 JSON endpoints with parameterized queries, sort whitelists

SQL fixes:
- 05_aggregation_tables: add deduplicate_merge_projection_mode
- 11_views: fix nested aggregate (argMax inside sum)
- 12_thesis_features: remove invalid 'let' bindings, fix groupArrayIf type

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-08 03:21:05 +02:00
parent 228ad7026a
commit b735bab5a5
120 changed files with 1444 additions and 24933 deletions

View File

@ -0,0 +1,131 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — IP {{ ip }}{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex items-center gap-3">
<a href="/detections" class="text-gray-500 hover:text-gray-300">&larr; Retour</a>
<h2 class="text-lg font-semibold text-white">Investigation IP : <span class="text-brand-500">{{ ip }}</span></h2>
</div>
<!-- KPI Row -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4" id="ip-kpis">
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Détections</div><div class="text-xl font-bold text-red-400" id="ip-det-count"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Pire score</div><div class="text-xl font-bold" id="ip-worst-score"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Récurrence</div><div class="text-xl font-bold text-yellow-400" id="ip-recurrence"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Requêtes HTTP</div><div class="text-xl font-bold text-gray-200" id="ip-http-count"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Scores ML</div><div class="text-xl font-bold text-brand-500" id="ip-score-count"></div></div>
</div>
<!-- Score timeline -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Scores ML dans le temps</h3>
<canvas id="score-chart" height="150"></canvas>
</div>
<!-- Detections -->
<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">Détections</h3>
<div class="overflow-x-auto max-h-[40vh] overflow-y-auto">
<table class="data-table"><thead><tr>
<th>Date</th><th>Score</th><th>Raw</th><th>Threat</th><th>JA4</th><th>Host</th><th>Hits</th><th>Raison</th>
</tr></thead><tbody id="det-body"></tbody></table>
</div>
</div>
<!-- AI Features -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden" id="features-section" style="display:none">
<h3 class="text-sm font-medium text-gray-400 px-5 py-3 border-b border-gray-800">Features AI</h3>
<div class="p-5 grid grid-cols-2 md:grid-cols-4 gap-3 text-sm" id="features-grid"></div>
</div>
<!-- HTTP Logs -->
<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">Dernières requêtes HTTP (100 max)</h3>
<div class="overflow-x-auto max-h-[40vh] overflow-y-auto">
<table class="data-table"><thead><tr>
<th>Time</th><th>Method</th><th>Host</th><th>Path</th><th>HTTP Ver</th><th>User-Agent</th><th>JA4</th>
</tr></thead><tbody id="http-body"></tbody></table>
</div>
</div>
<!-- Classify -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Classifier cette IP</h3>
<div class="flex gap-3 items-center">
<select id="cls-select" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300">
<option value="bot">🤖 Bot</option><option value="legitimate">✅ Légitime</option><option value="suspicious">⚠️ Suspect</option>
</select>
<input type="text" id="cls-comment" placeholder="Commentaire (optionnel)" class="flex-1 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">
<button id="cls-btn" class="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700">Envoyer</button>
</div>
<div id="cls-result" class="mt-2 text-sm"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const IP = "{{ ip }}";
let scoreChart;
async function loadIP() {
try {
const r = await fetch(`/api/ip/${encodeURIComponent(IP)}`); const d = await r.json();
document.getElementById('ip-det-count').textContent = d.detections?.length ?? 0;
document.getElementById('ip-http-count').textContent = d.http_logs?.length ?? 0;
document.getElementById('ip-score-count').textContent = d.scores?.length ?? 0;
if (d.recurrence?.length) {
const rec = d.recurrence[0];
document.getElementById('ip-recurrence').textContent = rec.recurrence || 0;
document.getElementById('ip-worst-score').innerHTML = fmtScore(rec.worst_score);
}
// Detections table
document.getElementById('det-body').innerHTML = (d.detections||[]).map(row => `<tr>
<td class="text-xs whitespace-nowrap">${row.detected_at||''}</td>
<td>${fmtScore(row.anomaly_score)}</td>
<td>${fmtScore(row.raw_anomaly_score)}</td>
<td>${threatBadge(row.threat_level)}</td>
<td class="text-xs font-mono max-w-[100px] truncate">${row.ja4||''}</td>
<td class="text-xs max-w-[120px] truncate">${row.host||''}</td>
<td>${row.hits||0}</td>
<td class="text-xs max-w-[200px] truncate">${row.reason||''}</td>
</tr>`).join('') || '<tr><td colspan="8" class="text-center text-gray-500 py-4">Aucune détection</td></tr>';
// Score chart
if (d.scores?.length) {
const labels = d.scores.map(s => (s.detected_at||'').substring(11,16));
const data = d.scores.map(s => s.anomaly_score);
if (scoreChart) scoreChart.destroy();
scoreChart = new Chart(document.getElementById('score-chart'), {
type:'line', data:{labels:labels.reverse(), datasets:[{label:'Score',data:data.reverse(),
borderColor:'#6366f1',backgroundColor:'rgba(99,102,241,0.1)',fill:true,tension:0.3,pointRadius:2}]},
options:{responsive:true,plugins:{legend:{display:false}},scales:{y:{min:0,max:1,ticks:{color:'#9ca3af'}},x:{ticks:{color:'#9ca3af',maxTicksLimit:12}}}}
});
}
// AI Features
if (d.ai_features?.length) {
const f = d.ai_features[0];
const grid = document.getElementById('features-grid');
const skip = new Set(['src_ip','window_start','ja4','host','bot_name','src_ip_str']);
grid.innerHTML = Object.entries(f).filter(([k])=>!skip.has(k)).map(([k,v]) => {
let val = typeof v === 'number' ? v.toFixed(4) : v;
return `<div class="bg-gray-800 rounded-lg p-2"><div class="text-[10px] text-gray-500 truncate">${k}</div><div class="text-sm text-gray-200 font-mono">${val}</div></div>`;
}).join('');
document.getElementById('features-section').style.display = '';
}
// HTTP logs
document.getElementById('http-body').innerHTML = (d.http_logs||[]).map(row => `<tr>
<td class="text-xs whitespace-nowrap">${row.time||''}</td>
<td class="font-mono text-xs">${row.method||''}</td>
<td class="text-xs max-w-[120px] 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">${row.header_user_agent||''}</td>
<td class="text-xs font-mono">${row.ja4||''}</td>
</tr>`).join('') || '<tr><td colspan="7" class="text-center text-gray-500 py-4">Aucun log</td></tr>';
} catch(e) { console.error(e); }
}
document.getElementById('cls-btn').onclick = async () => {
try {
const r = await fetch('/api/classify', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({src_ip:IP, classification:document.getElementById('cls-select').value, comment:document.getElementById('cls-comment').value})});
const d = await r.json();
document.getElementById('cls-result').innerHTML = r.ok
? `<span class="text-green-400">✓ Classifié : ${d.classification}</span>`
: `<span class="text-red-400">✗ Erreur : ${d.detail||'unknown'}</span>`;
} catch(e) { document.getElementById('cls-result').innerHTML = `<span class="text-red-400">✗ ${e}</span>`; }
};
loadIP();
</script>
{% endblock %}