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>
132 lines
8.3 KiB
HTML
132 lines
8.3 KiB
HTML
{% 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">← 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 %}
|