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,100 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}JA4 SOC Dashboard{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: { 50:'#eef2ff',100:'#e0e7ff',500:'#6366f1',600:'#4f46e5',700:'#4338ca',900:'#312e81' },
}
}
}
}
</script>
<style>
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
.threat-critical { color: #ef4444; font-weight: 700; }
.threat-high { color: #f97316; font-weight: 600; }
.threat-medium { color: #eab308; }
.threat-low { color: #22c55e; }
.threat-normal { color: #6b7280; }
.kpi-card { @apply bg-gray-800 rounded-xl p-5 border border-gray-700; }
.data-table { @apply w-full text-sm text-left; }
.data-table th { @apply px-4 py-3 bg-gray-800 text-gray-300 font-medium border-b border-gray-700 sticky top-0; }
.data-table td { @apply px-4 py-2.5 border-b border-gray-800 text-gray-300; }
.data-table tbody tr:hover { @apply bg-gray-800/50; }
.nav-link { @apply px-4 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors text-sm font-medium; }
.nav-link.active { @apply bg-brand-600 text-white; }
.badge { @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium; }
.badge-critical { @apply bg-red-500/20 text-red-400; }
.badge-high { @apply bg-orange-500/20 text-orange-400; }
.badge-medium { @apply bg-yellow-500/20 text-yellow-400; }
.badge-low { @apply bg-green-500/20 text-green-400; }
.badge-normal { @apply bg-gray-500/20 text-gray-400; }
.badge-known { @apply bg-blue-500/20 text-blue-400; }
.htmx-request .htmx-indicator { display: inline-block; }
.htmx-indicator { display: none; }
.filter-btn { @apply px-3 py-1.5 text-xs rounded-lg border border-gray-700 text-gray-400 hover:border-brand-500 hover:text-brand-500 transition-colors cursor-pointer; }
.filter-btn.active { @apply border-brand-500 bg-brand-500/20 text-brand-500; }
</style>
{% block head %}{% endblock %}
</head>
<body class="bg-gray-950 text-gray-200 min-h-screen">
<!-- Top Nav -->
<nav class="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
<div class="max-w-[1600px] mx-auto px-4 flex items-center h-14 gap-2">
<a href="/" class="flex items-center gap-2 mr-6">
<div class="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-sm">J4</div>
<span class="text-white font-semibold hidden sm:inline">JA4 SOC</span>
</a>
<a href="/" class="nav-link {% if active_page == 'overview' %}active{% endif %}">Overview</a>
<a href="/detections" class="nav-link {% if active_page == 'detections' %}active{% endif %}">Détections</a>
<a href="/scores" class="nav-link {% if active_page == 'scores' %}active{% endif %}">Scores</a>
<a href="/traffic" class="nav-link {% if active_page == 'traffic' %}active{% endif %}">Trafic</a>
<a href="/features" class="nav-link {% if active_page == 'features' %}active{% endif %}">Features</a>
<a href="/models" class="nav-link {% if active_page == 'models' %}active{% endif %}">Modèles</a>
<a href="/classify" class="nav-link {% if active_page == 'classify' %}active{% endif %}">Classifier</a>
<div class="flex-1"></div>
<span class="text-xs text-gray-500" id="clock"></span>
</div>
</nav>
<!-- Content -->
<main class="max-w-[1600px] mx-auto px-4 py-6">
{% block content %}{% endblock %}
</main>
<script>
function updateClock() {
document.getElementById('clock').textContent = new Date().toLocaleString('fr-FR');
}
updateClock(); setInterval(updateClock, 1000);
function threatBadge(level) {
const map = {
'CRITICAL':'badge-critical','HIGH':'badge-high','MEDIUM':'badge-medium',
'LOW':'badge-low','NORMAL':'badge-normal','KNOWN_BOT':'badge-known','ANUBIS_DENY':'badge-critical'
};
return `<span class="badge ${map[level]||'badge-normal'}">${level}</span>`;
}
function fmtIP(ip) {
if (!ip) return '';
let s = String(ip).replace('::ffff:','');
return `<a href="/ip/${encodeURIComponent(s)}" class="text-brand-500 hover:underline">${s}</a>`;
}
function fmtScore(v) {
let n = parseFloat(v);
if (isNaN(n)) return '-';
let color = n > 0.7 ? 'text-red-400' : n > 0.4 ? 'text-orange-400' : n > 0.1 ? 'text-yellow-400' : 'text-green-400';
return `<span class="${color}">${n.toFixed(4)}</span>`;
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Classifier{% endblock %}
{% block content %}
<div class="space-y-6 max-w-2xl">
<h2 class="text-lg font-semibold text-white">Classification SOC</h2>
<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>
<!-- 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">
<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>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('cls-submit').onclick = async () => {
const ip = document.getElementById('cls-ip').value.trim();
if (!ip) { alert('Veuillez saisir une IP'); 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})});
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();
} 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) {}
}
loadHistory();
</script>
{% endblock %}

View File

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Détections{% endblock %}
{% block content %}
<div class="space-y-4">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-white">Anomalies détectées</h2>
<div class="flex gap-1.5" id="threat-filters">
<button class="filter-btn active" data-filter="">Tous</button>
<button class="filter-btn" data-filter="CRITICAL">Critical</button>
<button class="filter-btn" data-filter="HIGH">High</button>
<button class="filter-btn" data-filter="MEDIUM">Medium</button>
<button class="filter-btn" data-filter="KNOWN_BOT">Known Bot</button>
<button class="filter-btn" data-filter="ANUBIS_DENY">Anubis Deny</button>
</div>
<div class="flex-1"></div>
<input type="text" id="search-input" placeholder="Rechercher IP, 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">
</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">
<table class="data-table">
<thead><tr>
<th class="cursor-pointer" data-sort="detected_at">Date ↕</th>
<th>IP</th>
<th class="cursor-pointer" data-sort="anomaly_score">Score ↕</th>
<th>Threat</th>
<th>JA4</th>
<th>Host</th>
<th>Hits</th>
<th>ASN</th>
<th>Pays</th>
<th>Récurrence</th>
<th>Raison</th>
</tr></thead>
<tbody id="detections-body"></tbody>
</table>
</div>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-800">
<span class="text-xs text-gray-500" id="det-info"></span>
<div class="flex gap-2">
<button id="prev-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&larr; Précédent</button>
<button id="next-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">Suivant &rarr;</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let dPage=1, dSort='detected_at', dOrder='DESC', dThreat='', dSearch='';
async function loadDetections() {
const params = new URLSearchParams({page:dPage,per_page:50,sort:dSort,order:dOrder});
if(dThreat) params.set('threat_level',dThreat);
if(dSearch) params.set('search',dSearch);
try {
const r = await fetch('/api/detections?'+params);
const d = await r.json();
const tbody = document.getElementById('detections-body');
tbody.innerHTML = (d.data||[]).map(row => `<tr>
<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>
<td>${threatBadge(row.threat_level)}</td>
<td class="text-xs font-mono max-w-[120px] truncate" title="${row.ja4||''}">${row.ja4||''}</td>
<td class="text-xs max-w-[150px] truncate" title="${row.host||''}">${row.host||''}</td>
<td>${row.hits||0}</td>
<td class="text-xs max-w-[150px] truncate">${row.asn_org||''}</td>
<td>${row.country_code||''}</td>
<td>${row.recurrence||0}</td>
<td class="text-xs max-w-[200px] truncate" title="${row.reason||''}">${row.reason||''}</td>
</tr>`).join('') || '<tr><td colspan="11" class="text-center text-gray-500 py-8">Aucune détection</td></tr>';
const total = d.total||0;
document.getElementById('det-info').textContent = `${total} résultats — page ${dPage}/${Math.max(1,Math.ceil(total/50))}`;
document.getElementById('prev-btn').disabled = dPage <= 1;
document.getElementById('next-btn').disabled = dPage * 50 >= total;
} catch(e) { console.error(e); }
}
document.getElementById('prev-btn').onclick = () => { if(dPage>1){dPage--;loadDetections();} };
document.getElementById('next-btn').onclick = () => { dPage++;loadDetections(); };
document.querySelectorAll('[data-sort]').forEach(th => th.onclick = () => {
const s = th.dataset.sort;
if(dSort===s) dOrder = dOrder==='DESC'?'ASC':'DESC'; else { dSort=s; dOrder='DESC'; }
dPage=1; loadDetections();
});
document.querySelectorAll('[data-filter]').forEach(btn => btn.onclick = () => {
document.querySelectorAll('[data-filter]').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
dThreat = btn.dataset.filter; dPage=1; loadDetections();
});
let searchTimeout;
document.getElementById('search-input').oninput = (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { dSearch=e.target.value; dPage=1; loadDetections(); }, 300);
};
loadDetections();
</script>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Features ML{% endblock %}
{% block content %}
<div class="space-y-6">
<h2 class="text-lg font-semibold text-white">Features ML — Statistiques agrégées</h2>
<!-- AI Features -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Features AI (view_ai_features_1h)</h3>
<div id="ai-stats" class="text-gray-500 text-sm">Chargement...</div>
</div>
<!-- Thesis Features -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Features Thèse §5 (view_thesis_features_1h)</h3>
<div id="thesis-stats" class="text-gray-500 text-sm">Chargement...</div>
</div>
<!-- Score distribution chart -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Distribution des scores d'anomalie</h3>
<canvas id="score-dist-chart" height="200"></canvas>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function renderStats(data, containerId) {
const el = document.getElementById(containerId);
if (!data || Object.keys(data).length === 0) { el.textContent = 'Aucune donnée disponible'; return; }
el.innerHTML = '<div class="grid grid-cols-2 md:grid-cols-4 gap-3">' +
Object.entries(data).map(([k,v]) => {
let val = typeof v === 'number' ? v.toFixed(4) : v;
return `<div class="bg-gray-800 rounded-lg p-3"><div class="text-[10px] text-gray-500 truncate">${k}</div><div class="text-sm text-gray-200 font-mono">${val}</div></div>`;
}).join('') + '</div>';
}
async function loadFeatures() {
try {
const r = await fetch('/api/features'); const d = await r.json();
renderStats(d.ai_features, 'ai-stats');
renderStats(d.thesis_features, 'thesis-stats');
} catch(e) { console.error(e); }
}
loadFeatures();
</script>
{% endblock %}

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 %}

View File

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Modèles{% endblock %}
{% block content %}
<div class="space-y-6">
<h2 class="text-lg font-semibold text-white">État des modèles ML</h2>
<!-- 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="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">
<span class="text-sm text-gray-500">Chargement...</span>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
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>';
}
} catch(e) { console.error(e); }
}
loadModels();
</script>
{% endblock %}

View File

@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Overview{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- KPI Row -->
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4" id="kpi-grid">
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Détections 24h</div><div class="text-2xl font-bold text-red-400" id="kpi-detections"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Sessions scorées 24h</div><div class="text-2xl font-bold text-brand-500" id="kpi-scored"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Trafic total 24h</div><div class="text-2xl font-bold text-gray-200" id="kpi-traffic"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">IPs uniques</div><div class="text-2xl font-bold text-yellow-400" id="kpi-ips"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Critical/High</div><div class="text-2xl font-bold text-orange-400" id="kpi-critical"></div></div>
<div class="kpi-card"><div class="text-xs text-gray-500 mb-1">Modèles actifs</div><div class="text-2xl font-bold text-green-400" id="kpi-models"></div></div>
</div>
<!-- Charts Row -->
<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">Détections par heure (24h)</h3>
<canvas id="chart-timeline" height="200"></canvas>
</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">Distribution des threat levels</h3>
<canvas id="chart-threats" height="200"></canvas>
</div>
</div>
<!-- Top IPs -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Top 10 IPs détectées (24h)</h3>
<div class="overflow-x-auto">
<table class="data-table" id="top-ips-table">
<thead><tr><th>IP</th><th>Détections</th><th>Pire score</th><th>Threat Level</th><th>ASN</th><th>Pays</th></tr></thead>
<tbody id="top-ips-body"></tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let timelineChart, threatsChart;
async function loadOverview() {
try {
const r = await fetch('/api/overview');
const d = await r.json();
document.getElementById('kpi-detections').textContent = (d.detections_24h ?? 0).toLocaleString();
document.getElementById('kpi-scored').textContent = (d.scored_24h ?? 0).toLocaleString();
document.getElementById('kpi-traffic').textContent = (d.traffic_24h ?? 0).toLocaleString();
document.getElementById('kpi-ips').textContent = (d.unique_ips ?? 0).toLocaleString();
document.getElementById('kpi-critical').textContent = ((d.critical_count ?? 0) + (d.high_count ?? 0)).toLocaleString();
document.getElementById('kpi-models').textContent = d.models?.length ?? 0;
// Timeline chart
if (d.timeline && d.timeline.length) {
const labels = d.timeline.map(t => t.hour?.substring(11,16) || '');
const data = d.timeline.map(t => t.cnt);
if (timelineChart) timelineChart.destroy();
timelineChart = new Chart(document.getElementById('chart-timeline'), {
type: 'bar', data: { labels, datasets: [{ label:'Détections', data, backgroundColor:'rgba(99,102,241,0.6)', borderColor:'#6366f1', borderWidth:1 }] },
options: { responsive:true, plugins:{legend:{display:false}}, scales:{ y:{beginAtZero:true,ticks:{color:'#9ca3af'}}, x:{ticks:{color:'#9ca3af'}} } }
});
}
// Threats donut
if (d.threat_distribution && d.threat_distribution.length) {
const labels = d.threat_distribution.map(t => t.threat_level);
const data = d.threat_distribution.map(t => t.cnt);
const colors = labels.map(l => ({CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',LOW:'#22c55e',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',ANUBIS_DENY:'#dc2626'}[l]||'#6b7280'));
if (threatsChart) threatsChart.destroy();
threatsChart = new Chart(document.getElementById('chart-threats'), {
type:'doughnut', data:{labels,datasets:[{data,backgroundColor:colors}]},
options:{responsive:true,plugins:{legend:{position:'right',labels:{color:'#9ca3af',font:{size:11}}}}}
});
}
// Top IPs table
const tbody = document.getElementById('top-ips-body');
tbody.innerHTML = (d.top_ips||[]).map(ip => `<tr>
<td>${fmtIP(ip.src_ip)}</td><td>${ip.cnt}</td><td>${fmtScore(ip.worst_score)}</td>
<td>${threatBadge(ip.threat_level||'')}</td><td class="text-xs">${ip.asn_org||''}</td><td>${ip.country_code||''}</td>
</tr>`).join('');
} catch(e) { console.error('Overview load error:', e); }
}
loadOverview();
setInterval(loadOverview, 30000);
</script>
{% endblock %}

View File

@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Scores ML{% endblock %}
{% block content %}
<div class="space-y-4">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-white">Toutes les classifications ML</h2>
<div class="flex gap-1.5" id="threat-filters">
<button class="filter-btn active" data-filter="">Tous</button>
<button class="filter-btn" data-filter="CRITICAL">Critical</button>
<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>
</div>
</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">
<table class="data-table">
<thead><tr>
<th class="cursor-pointer" data-sort="detected_at">Date ↕</th>
<th>IP</th>
<th class="cursor-pointer" data-sort="anomaly_score">Score ↕</th>
<th class="cursor-pointer" data-sort="raw_anomaly_score">Raw ↕</th>
<th>AE Error</th>
<th>XGB Prob</th>
<th>Threat</th>
<th>Model</th>
<th>JA4</th>
<th>Host</th>
<th>Hits</th>
<th>Pays</th>
</tr></thead>
<tbody id="scores-body"></tbody>
</table>
</div>
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-800">
<span class="text-xs text-gray-500" id="scores-info"></span>
<div class="flex gap-2">
<button id="prev-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&larr;</button>
<button id="next-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&rarr;</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let sPage=1, sSort='detected_at', sOrder='DESC', sThreat='';
async function loadScores() {
const params = new URLSearchParams({page:sPage,per_page:50,sort:sSort,order:sOrder});
if(sThreat) params.set('threat_level',sThreat);
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>
<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>
<td>${fmtScore(row.raw_anomaly_score)}</td>
<td class="text-xs">${(row.ae_recon_error||0).toFixed(6)}</td>
<td class="text-xs">${(row.xgb_prob||0).toFixed(4)}</td>
<td>${threatBadge(row.threat_level)}</td>
<td class="text-xs">${row.model_name||''}</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>${row.country_code||''}</td>
</tr>`).join('') || '<tr><td colspan="12" class="text-center text-gray-500 py-8">Aucun score</td></tr>';
const total = d.total||0;
document.getElementById('scores-info').textContent = `${total} résultats — page ${sPage}/${Math.max(1,Math.ceil(total/50))}`;
document.getElementById('prev-btn').disabled = sPage <= 1;
document.getElementById('next-btn').disabled = sPage * 50 >= total;
} catch(e) { console.error(e); }
}
document.getElementById('prev-btn').onclick = () => { if(sPage>1){sPage--;loadScores();} };
document.getElementById('next-btn').onclick = () => { sPage++;loadScores(); };
document.querySelectorAll('[data-sort]').forEach(th => th.onclick = () => {
const s = th.dataset.sort;
if(sSort===s) sOrder = sOrder==='DESC'?'ASC':'DESC'; else { sSort=s; sOrder='DESC'; }
sPage=1; loadScores();
});
document.querySelectorAll('[data-filter]').forEach(btn => btn.onclick = () => {
document.querySelectorAll('[data-filter]').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
sThreat = btn.dataset.filter; sPage=1; loadScores();
});
loadScores();
</script>
{% endblock %}

View File

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Trafic HTTP{% endblock %}
{% block content %}
<div class="space-y-4">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-white">Logs HTTP (24h)</h2>
<select id="method-filter" class="px-2 py-1 bg-gray-800 border border-gray-700 rounded text-sm text-gray-300">
<option value="">Toutes méthodes</option>
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option><option>HEAD</option><option>OPTIONS</option>
</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">
</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">
<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>
</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">
<span class="text-xs text-gray-500" id="traffic-info"></span>
<div class="flex gap-2">
<button id="prev-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&larr;</button>
<button id="next-btn" class="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 hover:text-white disabled:opacity-30">&rarr;</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let tPage=1;
async function loadTraffic() {
const params = new URLSearchParams({page:tPage,per_page:100});
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);
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>
<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="text-xs font-mono max-w-[100px] truncate">${row.ja4||''}</td>
<td>${row.src_country_code||''}</td>
</tr>`).join('') || '<tr><td colspan="9" 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('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=>{
let el=document.getElementById(id);
el.addEventListener(el.tagName==='SELECT'?'change':'input',()=>{tPage=1;loadTraffic();});
});
loadTraffic();
</script>
{% endblock %}