feat(dashboard): add clickable drill-down to all data elements

Add navigation helpers (fmtASN, fmtCountry, fmtJA4, fmtBotName,
fmtThreatLink, fmtLabel) to base.html for SOC analyst drill-down.

Update all templates:
- overview.html: clickable table cells + ECharts click handlers for
  ASN, country, JA4, bot, and threat charts
- detections.html: URL param pre-filters, active filter bar with
  clear buttons, clickable ASN/country/JA4/threat in table
- scores.html: URL param pre-filters, clickable threat/JA4/country
- traffic.html: clickable JA4 and country columns
- ip_detail.html: clickable threat/JA4 in detections, clickable
  asn_org/country_code/asn_label in AI features grid
- network.html: click handlers on ASN treemap and country sunburst,
  fmtJA4Full/fmtLabel/fmtBotName/fmtASN in tables
- features.html: scatter plot click navigates to /ip/{ip}

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-08 14:58:48 +02:00
parent fc882dd3e7
commit c6ca352db9
8 changed files with 1148 additions and 112 deletions

View File

@ -4,9 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}JA4 SOC Dashboard{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<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 src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
@ -14,7 +16,10 @@
extend: {
colors: {
brand: { 50:'#eef2ff',100:'#e0e7ff',500:'#6366f1',600:'#4f46e5',700:'#4338ca',900:'#312e81' },
}
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
}
}
}
@ -40,16 +45,41 @@
.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; }
/* Micro-animations */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in { animation: fadeUp 0.4s ease-out both; }
.kpi-card { animation: fadeUp 0.4s ease-out both; }
.kpi-card:nth-child(1) { animation-delay: 0ms; }
.kpi-card:nth-child(2) { animation-delay: 50ms; }
.kpi-card:nth-child(3) { animation-delay: 100ms; }
.kpi-card:nth-child(4) { animation-delay: 150ms; }
.kpi-card:nth-child(5) { animation-delay: 200ms; }
.kpi-card:nth-child(6) { animation-delay: 250ms; }
/* Live status pulse */
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.live-dot {
width: 6px; height: 6px;
background: #22c55e;
border-radius: 50%;
display: inline-block;
animation: pulse-dot 1.5s ease-in-out infinite;
}
</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">
<nav class="sticky top-0 z-50 border-b border-gray-800" style="background: linear-gradient(135deg, #0f172a 0%, #111827 50%, #0f172a 100%);">
<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>
@ -60,10 +90,14 @@
<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="/network" class="nav-link {% if active_page == 'network' %}active{% endif %}">Réseau</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>
<span class="flex items-center gap-2 text-xs text-gray-500">
<span class="live-dot"></span>
<span id="clock"></span>
</span>
</div>
</nav>
<!-- Content -->
@ -71,11 +105,13 @@
{% block content %}{% endblock %}
</main>
<script>
// ── Clock ──
function updateClock() {
document.getElementById('clock').textContent = new Date().toLocaleString('fr-FR');
}
updateClock(); setInterval(updateClock, 1000);
// ── Existing helpers ──
function threatBadge(level) {
const map = {
'CRITICAL':'badge-critical','HIGH':'badge-high','MEDIUM':'badge-medium',
@ -94,6 +130,58 @@
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>`;
}
// ── Navigation helpers ──
function fmtASN(org) {
if (!org) return '';
return `<a href="/detections?asn_org=${encodeURIComponent(org)}" class="text-blue-400 hover:underline cursor-pointer">${org}</a>`;
}
function fmtCountry(cc) {
if (!cc) return '';
const flags = {'FR':'🇫🇷','DE':'🇩🇪','NL':'🇳🇱','GB':'🇬🇧','ES':'🇪🇸','US':'🇺🇸','RU':'🇷🇺','IT':'🇮🇹','JP':'🇯🇵','CN':'🇨🇳','KR':'🇰🇷','BR':'🇧🇷','AU':'🇦🇺','CA':'🇨🇦','IN':'🇮🇳'};
return `<a href="/detections?country_code=${encodeURIComponent(cc)}" class="hover:underline cursor-pointer">${flags[cc]||'🏳️'} ${cc}</a>`;
}
function fmtJA4(ja4) {
if (!ja4) return '';
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" class="text-purple-400 hover:underline cursor-pointer font-mono text-xs" title="${ja4}">${ja4.substring(0,20)}…</a>`;
}
function fmtJA4Full(ja4) {
if (!ja4) return '';
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" class="text-purple-400 hover:underline cursor-pointer font-mono text-xs">${ja4}</a>`;
}
function fmtBotName(name) {
if (!name) return '';
return `<a href="/detections?bot_name=${encodeURIComponent(name)}" class="text-cyan-400 hover:underline cursor-pointer">${name}</a>`;
}
function fmtThreatLink(level) {
if (!level) return '';
return `<a href="/detections?threat_level=${encodeURIComponent(level)}" class="cursor-pointer">${threatBadge(level)}</a>`;
}
function fmtLabel(label) {
if (!label) return '';
const colors = {human:'text-green-400 bg-green-500/10',datacenter:'text-red-400 bg-red-500/10',hosting:'text-orange-400 bg-orange-500/10'};
return `<span class="px-1.5 py-0.5 rounded text-xs ${colors[label]||'text-gray-400 bg-gray-500/10'}">${label}</span>`;
}
// ── ECharts helpers ──
const EC_COLORS = ['#6366f1','#22c55e','#f97316','#ef4444','#3b82f6','#eab308','#ec4899','#14b8a6','#8b5cf6','#f43f5e'];
const EC_TEXT = '#9ca3af';
const EC_GRID = '#374151';
function ecBase(overrides) {
return Object.assign({
backgroundColor: 'transparent',
textStyle: { color: EC_TEXT, fontFamily: 'Inter, system-ui, sans-serif' },
animation: true, animationDuration: 600,
}, overrides);
}
function ecTooltip(extra) {
return Object.assign({
backgroundColor: '#1f2937', borderColor: '#374151',
textStyle: { color: '#e5e7eb', fontSize: 12 },
}, extra);
}
</script>
{% block scripts %}{% endblock %}
</body>

View File

@ -2,6 +2,21 @@
{% block title %}JA4 SOC — Détections{% endblock %}
{% block content %}
<div class="space-y-4">
<!-- Summary charts row -->
<div class="grid grid-cols-1 lg: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">Détections par threat level</h3>
<div id="det-threat-chart" style="height:160px"></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 raisons de détection</h3>
<div id="det-reason-chart" style="height:160px"></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 ASN détectés</h3>
<div id="det-asn-chart" style="height:160px"></div>
</div>
</div>
<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">
@ -16,6 +31,7 @@
<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 id="active-filters" class="flex gap-2 flex-wrap"></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">
@ -48,10 +64,35 @@
{% block scripts %}
<script>
let dPage=1, dSort='detected_at', dOrder='DESC', dThreat='', dSearch='';
const urlParams = new URLSearchParams(window.location.search);
let dASN=urlParams.get('asn_org')||'',
dCountry=urlParams.get('country_code')||'',
dJA4=urlParams.get('ja4')||'',
dBotName=urlParams.get('bot_name')||'';
if(urlParams.get('threat_level')) dThreat=urlParams.get('threat_level');
if(urlParams.get('search')) dSearch=urlParams.get('search');
function renderActiveFilters() {
const el = document.getElementById('active-filters');
const filters = [];
if(dASN) filters.push({label:'ASN: '+dASN, clear:()=>{dASN='';renderActiveFilters();loadDetections();}});
if(dCountry) filters.push({label:'Pays: '+dCountry, clear:()=>{dCountry='';renderActiveFilters();loadDetections();}});
if(dJA4) filters.push({label:'JA4: '+dJA4.substring(0,20), clear:()=>{dJA4='';renderActiveFilters();loadDetections();}});
if(dBotName) filters.push({label:'Bot: '+dBotName, clear:()=>{dBotName='';renderActiveFilters();loadDetections();}});
el.innerHTML = filters.map((f,i) =>
`<span class="inline-flex items-center gap-1 px-2 py-1 bg-brand-500/20 text-brand-400 rounded-lg text-xs">
${f.label} <button onclick="window._clearFilter${i}()" class="hover:text-white">✕</button>
</span>`).join('');
filters.forEach((f,i) => { window['_clearFilter'+i] = f.clear; });
}
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);
if(dASN) params.set('asn_org',dASN);
if(dCountry) params.set('country_code',dCountry);
if(dJA4) params.set('ja4',dJA4);
if(dBotName) params.set('bot_name',dBotName);
try {
const r = await fetch('/api/detections?'+params);
const d = await r.json();
@ -60,12 +101,12 @@ async function loadDetections() {
<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>${fmtThreatLink(row.threat_level)}</td>
<td class="text-xs font-mono max-w-[120px] truncate">${fmtJA4(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 class="text-xs max-w-[150px] truncate">${fmtASN(row.asn_org)}</td>
<td>${fmtCountry(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>';
@ -93,5 +134,47 @@ document.getElementById('search-input').oninput = (e) => {
searchTimeout = setTimeout(() => { dSearch=e.target.value; dPage=1; loadDetections(); }, 300);
};
loadDetections();
renderActiveFilters();
// Summary mini-charts (loaded once)
async function loadDetSummary() {
try {
const r = await fetch('/api/detections?per_page=500');
const d = await r.json();
const rows = d.data || [];
// Threat distribution
const threatCounts = {};
const reasonCounts = {};
const asnCounts = {};
rows.forEach(row => {
threatCounts[row.threat_level] = (threatCounts[row.threat_level]||0)+1;
if (row.reason) { const short = row.reason.substring(0,40); reasonCounts[short] = (reasonCounts[short]||0)+1; }
if (row.asn_org) asnCounts[row.asn_org] = (asnCounts[row.asn_org]||0)+1;
});
const THREAT_COLORS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',ANUBIS_DENY:'#dc2626'};
// Threat pie
const ch1 = echarts.init(document.getElementById('det-threat-chart'));
ch1.setOption(ecBase({tooltip:ecTooltip({trigger:'item'}),series:[{type:'pie',radius:['30%','65%'],label:{color:EC_TEXT,fontSize:10},
data:Object.entries(threatCounts).map(([k,v])=>({name:k,value:v,itemStyle:{color:THREAT_COLORS[k]||'#6b7280'}}))}]}));
// Reason bar
const topReasons = Object.entries(reasonCounts).sort((a,b)=>b[1]-a[1]).slice(0,5);
if (topReasons.length) {
const ch2 = echarts.init(document.getElementById('det-reason-chart'));
ch2.setOption(ecBase({tooltip:ecTooltip({trigger:'axis'}),grid:{left:10,right:40,top:5,bottom:5,containLabel:true},
yAxis:{type:'category',data:topReasons.map(r=>r[0]).reverse(),axisLabel:{color:EC_TEXT,fontSize:9,width:120,overflow:'truncate'},axisLine:{show:false}},
xAxis:{type:'value',show:false},
series:[{type:'bar',data:topReasons.map(r=>r[1]).reverse(),barWidth:'60%',itemStyle:{color:'#6366f1'},label:{show:true,position:'right',color:EC_TEXT,fontSize:10}}]}));
}
// ASN bar
const topASN = Object.entries(asnCounts).sort((a,b)=>b[1]-a[1]).slice(0,5);
if (topASN.length) {
const ch3 = echarts.init(document.getElementById('det-asn-chart'));
ch3.setOption(ecBase({tooltip:ecTooltip({trigger:'axis'}),grid:{left:10,right:40,top:5,bottom:5,containLabel:true},
yAxis:{type:'category',data:topASN.map(r=>r[0]).reverse(),axisLabel:{color:EC_TEXT,fontSize:9,width:120,overflow:'truncate'},axisLine:{show:false}},
xAxis:{type:'value',show:false},
series:[{type:'bar',data:topASN.map(r=>r[1]).reverse(),barWidth:'60%',itemStyle:{color:'#f97316'},label:{show:true,position:'right',color:EC_TEXT,fontSize:10}}]}));
}
} catch(e) { console.error(e); }
}
loadDetSummary();
</script>
{% endblock %}

View File

@ -2,42 +2,274 @@
{% 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 -->
<h2 class="text-lg font-semibold text-white">Features ML — Exploration</h2>
<!-- 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">Features AI (view_ai_features_1h)</h3>
<div id="ai-stats" class="text-gray-500 text-sm">Chargement...</div>
<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>
<!-- 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>
<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>
<!-- Score distribution chart -->
</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">Distribution des scores d'anomalie</h3>
<canvas id="score-dist-chart" height="200"></canvas>
<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>
<!-- Row 3: Distribution histograms (3-col grid) -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-xs font-medium text-gray-400 mb-2">hit_velocity</h3>
<div id="dist-hit_velocity" style="height:200px"></div>
</div>
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-xs font-medium text-gray-400 mb-2">fuzzing_index</h3>
<div id="dist-fuzzing_index" style="height:200px"></div>
</div>
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-xs font-medium text-gray-400 mb-2">post_ratio</h3>
<div id="dist-post_ratio" style="height:200px"></div>
</div>
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-xs font-medium text-gray-400 mb-2">asset_ratio</h3>
<div id="dist-asset_ratio" style="height:200px"></div>
</div>
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-xs font-medium text-gray-400 mb-2">temporal_entropy</h3>
<div id="dist-temporal_entropy" style="height:200px"></div>
</div>
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-xs font-medium text-gray-400 mb-2">path_diversity_ratio</h3>
<div id="dist-path_diversity_ratio" style="height:200px"></div>
</div>
</div>
<!-- Row 4: Temporal heatmap 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">Heatmap temporelle (jour × heure)</h3>
<div id="chart-heatmap" style="height:320px"></div>
</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>';
const LABEL_COLORS = {human:'#22c55e', datacenter:'#ef4444', hosting:'#f97316', unknown:'#6b7280'};
const DAYS = ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'];
const HOURS = Array.from({length:24}, (_,i) => String(i).padStart(2,'0')+'h');
let charts = {};
function initChart(id) {
const el = document.getElementById(id);
if (!el) return null;
if (charts[id]) charts[id].dispose();
charts[id] = echarts.init(el);
return charts[id];
}
async function loadFeatures() {
async function loadAll() {
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); }
const [feat, behav, heat] = await Promise.all([
fetch('/api/features').then(r => r.json()),
fetch('/api/behavior').then(r => r.json()),
fetch('/api/heatmap').then(r => r.json()),
]);
// ── Radar: Human vs Bot profiles ──
const radarKeys = [
{key:'avg_velocity', label:'Velocity'},
{key:'avg_fuzz', label:'Fuzzing'},
{key:'avg_post', label:'POST ratio'},
{key:'avg_asset', label:'Asset ratio'},
{key:'avg_direct', label:'Direct access'},
{key:'avg_entropy', label:'Entropy'},
{key:'avg_path_div', label:'Path diversity'},
{key:'avg_browser', label:'Browser score'},
];
const hp = feat.human_profile || {};
const bp = feat.bot_profile || {};
const maxVals = radarKeys.map(f => Math.max(hp[f.key] || 0, bp[f.key] || 0) || 1);
const radarChart = initChart('chart-radar');
if (radarChart) {
radarChart.setOption(ecBase({
tooltip: ecTooltip(),
legend: {data:['Humain','Bot'], top:10, textStyle:{color:EC_TEXT}},
radar: {
indicator: radarKeys.map((f,i) => ({name:f.label, max:1})),
shape:'polygon',
splitArea:{areaStyle:{color:['rgba(99,102,241,0.05)','rgba(99,102,241,0.1)']}},
splitLine:{lineStyle:{color:EC_GRID}},
axisLine:{lineStyle:{color:EC_GRID}},
axisName:{color:EC_TEXT, fontSize:11},
},
series:[{
type:'radar',
data:[
{
name:'Humain',
value: radarKeys.map((f,i) => (hp[f.key]||0) / maxVals[i]),
areaStyle:{color:'rgba(34,197,94,0.2)'},
lineStyle:{color:'#22c55e', width:2},
itemStyle:{color:'#22c55e'},
},
{
name:'Bot',
value: radarKeys.map((f,i) => (bp[f.key]||0) / maxVals[i]),
areaStyle:{color:'rgba(239,68,68,0.2)'},
lineStyle:{color:'#ef4444', width:2},
itemStyle:{color:'#ef4444'},
},
]
}]
}));
}
loadFeatures();
// ── Feature Importance (horizontal bar) ──
const fi = (feat.feature_importance || []).sort((a,b) => a.variance - b.variance);
const impChart = initChart('chart-importance');
if (impChart && fi.length) {
impChart.setOption(ecBase({
tooltip: ecTooltip({trigger:'axis', axisPointer:{type:'shadow'}}),
grid: {left:150, right:30, top:10, bottom:30},
yAxis: {
type:'category',
data: fi.map(f => f.name),
axisLine:{lineStyle:{color:EC_GRID}},
axisLabel:{color:EC_TEXT, fontSize:11, width:140, overflow:'truncate'},
},
xAxis: {
type:'value',
splitLine:{lineStyle:{color:EC_GRID, type:'dashed'}},
axisLabel:{color:EC_TEXT},
name:'Variance', nameTextStyle:{color:EC_TEXT},
},
series:[{
type:'bar', data: fi.map(f => f.variance), barWidth:'60%',
itemStyle:{color: new echarts.graphic.LinearGradient(0,0,1,0,[
{offset:0, color:'#6366f1'}, {offset:1, color:'#8b5cf6'}
])},
label:{show:true, position:'right', color:EC_TEXT, fontSize:10, formatter:p => p.value.toFixed(4)},
}]
}));
}
// ── Scatter: hit_velocity vs fuzzing_index ──
const scatter = behav.scatter || [];
const scatterChart = initChart('chart-scatter');
if (scatterChart && scatter.length) {
const groups = {};
scatter.forEach(p => {
const lbl = p.asn_label || 'unknown';
if (!groups[lbl]) groups[lbl] = [];
groups[lbl].push(p);
});
const series = Object.entries(groups).map(([label, pts]) => ({
name: label,
type: 'scatter',
data: pts.map(p => [
p.hit_velocity || 0,
p.fuzzing_index || 0,
Math.max(4, Math.min(30, Math.sqrt(p.hits || 1) * 2)),
p.ip, p.bot_name,
]),
symbolSize: (val) => val[2],
itemStyle: {color: LABEL_COLORS[label] || '#6b7280', opacity:0.75},
}));
scatterChart.setOption(ecBase({
tooltip: ecTooltip({
trigger:'item',
formatter: p => {
const d = p.data;
return `<b>${d[3]||''}</b><br>Label: ${p.seriesName}<br>` +
`Velocity: ${d[0].toFixed(3)}<br>Fuzzing: ${d[1].toFixed(3)}` +
(d[4] ? `<br>Bot: ${d[4]}` : '');
}
}),
legend: {data: Object.keys(groups), top:5, textStyle:{color:EC_TEXT}},
grid: {left:60, right:30, top:40, bottom:40},
xAxis: {
type:'value', name:'Hit Velocity', nameTextStyle:{color:EC_TEXT},
splitLine:{lineStyle:{color:EC_GRID, type:'dashed'}}, axisLabel:{color:EC_TEXT},
},
yAxis: {
type:'value', name:'Fuzzing Index', nameTextStyle:{color:EC_TEXT},
splitLine:{lineStyle:{color:EC_GRID, type:'dashed'}}, axisLabel:{color:EC_TEXT},
},
series,
}));
scatterChart.on('click', params => {
const ip = params.data?.[3];
if (ip) window.location.href = '/ip/' + encodeURIComponent(ip);
});
}
// ── Distribution histograms ──
const distKeys = ['hit_velocity','fuzzing_index','post_ratio','asset_ratio','temporal_entropy','path_diversity_ratio'];
const dists = behav.distributions || {};
distKeys.forEach(key => {
const data = dists[key] || [];
const ch = initChart('dist-' + key);
if (!ch || !data.length) return;
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'axis', axisPointer:{type:'shadow'}}),
grid: {left:45, right:10, top:8, bottom:25},
xAxis: {
type:'category', data: data.map(d => d.bucket),
axisLabel:{color:EC_TEXT, fontSize:9, rotate:30}, axisLine:{lineStyle:{color:EC_GRID}},
},
yAxis: {
type:'value', splitLine:{lineStyle:{color:EC_GRID, type:'dashed'}},
axisLabel:{color:EC_TEXT, fontSize:9},
},
series:[{
type:'bar', data: data.map(d => d.cnt), barWidth:'70%',
itemStyle:{color:'#6366f1', borderRadius:[2,2,0,0]},
}]
}));
});
// ── Temporal heatmap ──
const cells = heat.cells || [];
const heatChart = initChart('chart-heatmap');
if (heatChart && cells.length) {
const maxCnt = Math.max(...cells.map(c => c.cnt), 1);
heatChart.setOption(ecBase({
tooltip: ecTooltip({
formatter: p => `${DAYS[p.data[1]]} ${HOURS[p.data[0]]}<br>Requêtes: <b>${p.data[2]}</b>`
}),
grid: {left:60, right:40, top:10, bottom:30},
xAxis: {
type:'category', data:HOURS, splitArea:{show:true},
axisLabel:{color:EC_TEXT, fontSize:10}, axisLine:{lineStyle:{color:EC_GRID}},
},
yAxis: {
type:'category', data:DAYS, splitArea:{show:true},
axisLabel:{color:EC_TEXT, fontSize:11}, axisLine:{lineStyle:{color:EC_GRID}},
},
visualMap: {
min:0, max:maxCnt, calculable:true, orient:'vertical', right:0, top:'center',
inRange:{color:['#1e1b4b','#4338ca','#6366f1','#a78bfa','#f97316','#ef4444']},
textStyle:{color:EC_TEXT}, borderColor:'transparent',
},
series:[{
type:'heatmap',
data: cells.map(c => [c.hour, c.dow, c.cnt]),
label:{show:false},
emphasis:{itemStyle:{shadowBlur:6, shadowColor:'rgba(0,0,0,0.4)'}},
}]
}));
}
} catch(e) { console.error('Features load error:', e); }
}
loadAll();
setInterval(loadAll, 60000);
window.addEventListener('resize', () => Object.values(charts).forEach(c => c?.resize()));
</script>
{% endblock %}

View File

@ -7,19 +7,27 @@
<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="grid grid-cols-2 md:grid-cols-5 gap-4">
<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 -->
<!-- Charts Row: Radar + Score timeline -->
<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 comportemental (vs baseline)</h3>
<div id="radar-chart" style="height:320px"></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">Scores ML dans le temps</h3>
<canvas id="score-chart" height="150"></canvas>
<div id="score-chart" style="height:320px"></div>
</div>
<!-- Detections -->
</div>
<!-- Detections table -->
<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">
@ -28,11 +36,13 @@
</tr></thead><tbody id="det-body"></tbody></table>
</div>
</div>
<!-- AI Features -->
<!-- AI Features grid -->
<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 class="p-5 grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 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>
@ -42,6 +52,7 @@
</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>
@ -59,40 +70,93 @@
{% block scripts %}
<script>
const IP = "{{ ip }}";
let scoreChart;
let charts = {};
function initChart(id) {
const el = document.getElementById(id);
if (!el) return null;
if (charts[id]) charts[id].dispose();
charts[id] = echarts.init(el);
return charts[id];
}
async function loadIP() {
try {
const r = await fetch(`/api/ip/${encodeURIComponent(IP)}`); const d = await r.json();
const [d, radar] = await Promise.all([
fetch(`/api/ip/${encodeURIComponent(IP)}`).then(r=>r.json()),
fetch(`/api/ip/${encodeURIComponent(IP)}/radar`).then(r=>r.json()),
]);
// KPIs
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);
document.getElementById('ip-recurrence').textContent = d.recurrence[0].recurrence || 0;
document.getElementById('ip-worst-score').innerHTML = fmtScore(d.recurrence[0].worst_score);
}
// Radar chart
if (radar.features?.length && Object.keys(radar.ip_values).length) {
const labels = radar.features.map(f => f.replace('_',' '));
const ipVals = radar.features.map(f => radar.ip_values[f] ?? 0);
const humanVals = radar.features.map(f => radar.human_baseline[f] ?? 0);
const botVals = radar.features.map(f => radar.bot_baseline[f] ?? 0);
// Normalize to 0-1
const maxVals = radar.features.map((f,i) => Math.max(ipVals[i], humanVals[i], botVals[i], 0.001));
const norm = (arr) => arr.map((v,i) => +(v/maxVals[i]).toFixed(3));
const ch = initChart('radar-chart');
ch.setOption(ecBase({
tooltip: ecTooltip({}),
legend: {data:['Cette IP','Humain moyen','Bot moyen'], bottom:0, textStyle:{color:EC_TEXT,fontSize:11}},
radar: {
indicator: labels.map((l,i) => ({name:l, max:1})),
shape:'polygon',
splitArea:{areaStyle:{color:['rgba(99,102,241,0.02)','rgba(99,102,241,0.04)']}},
splitLine:{lineStyle:{color:EC_GRID}},
axisLine:{lineStyle:{color:EC_GRID}},
axisName:{color:EC_TEXT,fontSize:10},
},
series: [{
type:'radar',
data: [
{value:norm(ipVals), name:'Cette IP', lineStyle:{color:'#f97316',width:2}, areaStyle:{color:'rgba(249,115,22,0.15)'}, itemStyle:{color:'#f97316'}},
{value:norm(humanVals), name:'Humain moyen', lineStyle:{color:'#22c55e',width:1,type:'dashed'}, areaStyle:{color:'rgba(34,197,94,0.05)'}, itemStyle:{color:'#22c55e'}},
{value:norm(botVals), name:'Bot moyen', lineStyle:{color:'#ef4444',width:1,type:'dashed'}, areaStyle:{color:'rgba(239,68,68,0.05)'}, itemStyle:{color:'#ef4444'}},
]
}]
}));
}
// Score timeline
if (d.scores?.length) {
const scores = [...d.scores].reverse();
const ch = initChart('score-chart');
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'axis'}),
grid: {left:50,right:20,top:20,bottom:30},
xAxis: {type:'category', data:scores.map(s=>(s.detected_at||'').substring(11,16)), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT}},
yAxis: {type:'value', min:0, max:1, splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
series: [{
type:'line', data:scores.map(s=>s.anomaly_score), smooth:true,
areaStyle:{color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:'rgba(99,102,241,0.3)'},{offset:1,color:'rgba(99,102,241,0.02)'}])},
lineStyle:{color:'#6366f1',width:2}, itemStyle:{color:'#6366f1'}, symbol:'circle', symbolSize:4,
}]
}));
}
// 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>${fmtThreatLink(row.threat_level)}</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>${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];
@ -100,10 +164,18 @@ async function loadIP() {
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>`;
let color = 'text-gray-200';
if (typeof v === 'number' && v > 0.7) color = 'text-red-400';
else if (typeof v === 'number' && v > 0.4) color = 'text-orange-400';
let display = `<span class="text-sm ${color} font-mono">${val}</span>`;
if (k === 'asn_org' && v) display = `<span class="text-sm">${fmtASN(v)}</span>`;
else if (k === 'country_code' && v) display = `<span class="text-sm">${fmtCountry(v)}</span>`;
else if (k === 'asn_label' && v) display = `<span class="text-sm">${fmtLabel(v)}</span>`;
return `<div class="bg-gray-800 rounded-lg p-2"><div class="text-[10px] text-gray-500 truncate">${k}</div>${display}</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>
@ -114,8 +186,11 @@ async function loadIP() {
<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); }
}
// Classify button
document.getElementById('cls-btn').onclick = async () => {
try {
const r = await fetch('/api/classify', {method:'POST', headers:{'Content-Type':'application/json'},
@ -126,6 +201,8 @@ document.getElementById('cls-btn').onclick = async () => {
: `<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();
window.addEventListener('resize', () => Object.values(charts).forEach(c => c?.resize()));
</script>
{% endblock %}

View File

@ -0,0 +1,277 @@
{% extends "base.html" %}
{% block title %}JA4 SOC — Analyse Réseau{% endblock %}
{% block content %}
<div class="space-y-6">
<h2 class="text-lg font-semibold text-white">Analyse Réseau</h2>
<!-- KPI Row -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4" id="kpi-grid">
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-indigo-400"></span><span class="text-xs text-gray-500">Pays</span></div>
<div class="text-2xl font-bold text-brand-500" id="kpi-countries"></div>
</div>
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-yellow-400"></span><span class="text-xs text-gray-500">ASNs</span></div>
<div class="text-2xl font-bold text-yellow-400" id="kpi-asns"></div>
</div>
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-green-400"></span><span class="text-xs text-gray-500">Sessions humaines</span></div>
<div class="text-2xl font-bold text-green-400" id="kpi-human"></div>
</div>
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-red-400"></span><span class="text-xs text-gray-500">Sessions datacenter</span></div>
<div class="text-2xl font-bold text-red-400" id="kpi-datacenter"></div>
</div>
</div>
<!-- Row 2: ASN Treemap + Country Sunburst -->
<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">Treemap ASN (par label)</h3>
<div id="chart-treemap" style="height:380px"></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">Sunburst Pays → Label</h3>
<div id="chart-sunburst" style="height:380px"></div>
</div>
</div>
<!-- Row 3: JA4 fingerprint table -->
<div class="bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Empreintes JA4</h3>
<div class="overflow-x-auto" style="max-height:420px; overflow-y:auto">
<table class="data-table" id="ja4-table">
<thead><tr>
<th class="cursor-pointer select-none" data-col="0">JA4 ▸</th>
<th class="cursor-pointer select-none" data-col="1">Sessions ▸</th>
<th class="cursor-pointer select-none" data-col="2">Hits ▸</th>
<th class="cursor-pointer select-none" data-col="3">Avg Velocity ▸</th>
<th class="cursor-pointer select-none" data-col="4">Avg Fuzz ▸</th>
<th class="cursor-pointer select-none" data-col="5">Browser Score ▸</th>
<th>Label</th>
<th>Bot</th>
</tr></thead>
<tbody id="ja4-body"></tbody>
</table>
</div>
</div>
<!-- Row 4: ASN detail table + Bot pie -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="lg:col-span-2 bg-gray-900 rounded-xl p-5 border border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Détail ASN</h3>
<div class="overflow-x-auto" style="max-height:400px; overflow-y:auto">
<table class="data-table">
<thead><tr>
<th>ASN Org</th><th>Label</th><th>Pays</th>
<th>Sessions</th><th>Hits</th><th>Avg Velocity</th><th>Avg Fuzz</th>
</tr></thead>
<tbody id="asn-body"></tbody>
</table>
</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">Bots par empreinte</h3>
<div id="chart-botpie" style="height:340px"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const LABEL_COLORS = {human:'#22c55e', datacenter:'#ef4444', hosting:'#f97316', unknown:'#6b7280'};
function labelBadge(label) {
const colors = {
human: 'bg-green-500/20 text-green-400',
datacenter: 'bg-red-500/20 text-red-400',
hosting: 'bg-orange-500/20 text-orange-400',
unknown: 'bg-gray-500/20 text-gray-400',
};
return `<span class="badge ${colors[label] || colors.unknown}">${label || 'unknown'}</span>`;
}
let charts = {};
function initChart(id) {
const el = document.getElementById(id);
if (!el) return null;
if (charts[id]) charts[id].dispose();
charts[id] = echarts.init(el);
return charts[id];
}
/* ── Sortable JA4 table ── */
let ja4Rows = [];
let sortCol = 1, sortAsc = false;
function renderJA4Table() {
const sorted = [...ja4Rows].sort((a,b) => {
const va = a[sortCol], vb = b[sortCol];
if (typeof va === 'number' && typeof vb === 'number') return sortAsc ? va - vb : vb - va;
return sortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
document.getElementById('ja4-body').innerHTML = sorted.map(r =>
`<tr>
<td class="font-mono text-xs">${fmtJA4Full(r[0])}</td>
<td>${r[1]}</td><td>${r[2]}</td>
<td>${r[3].toFixed(3)}</td><td>${r[4].toFixed(3)}</td><td>${r[5].toFixed(2)}</td>
<td>${fmtLabel(r[6])}</td>
<td class="text-xs">${fmtBotName(r[7])}</td>
</tr>`
).join('');
}
document.getElementById('ja4-table').querySelector('thead').addEventListener('click', e => {
const th = e.target.closest('th[data-col]');
if (!th) return;
const col = parseInt(th.dataset.col);
if (col === sortCol) { sortAsc = !sortAsc; } else { sortCol = col; sortAsc = false; }
renderJA4Table();
});
async function loadAll() {
try {
const [geo, fp] = await Promise.all([
fetch('/api/geo').then(r => r.json()),
fetch('/api/fingerprints').then(r => r.json()),
]);
const countries = geo.countries || [];
const asns = geo.asns || [];
const ja4Stats = fp.ja4_stats || [];
const botJa4 = fp.bot_ja4 || [];
// ── KPIs ──
const uniqueCountries = new Set(countries.map(c => c.country_code)).size;
const uniqueAsns = new Set(asns.map(a => a.asn_org)).size;
const humanSessions = asns.filter(a => a.asn_label === 'human').reduce((s,a) => s + (a.sessions||0), 0);
const dcSessions = asns.filter(a => a.asn_label === 'datacenter').reduce((s,a) => s + (a.sessions||0), 0);
document.getElementById('kpi-countries').textContent = uniqueCountries.toLocaleString();
document.getElementById('kpi-asns').textContent = uniqueAsns.toLocaleString();
document.getElementById('kpi-human').textContent = humanSessions.toLocaleString();
document.getElementById('kpi-datacenter').textContent = dcSessions.toLocaleString();
// ── ASN Treemap grouped by asn_label ──
const treemapChart = initChart('chart-treemap');
if (treemapChart && asns.length) {
const byLabel = {};
asns.forEach(a => {
const lbl = a.asn_label || 'unknown';
if (!byLabel[lbl]) byLabel[lbl] = {name:lbl, value:0, children:[], itemStyle:{color:LABEL_COLORS[lbl]||'#6b7280'}};
byLabel[lbl].children.push({name:a.asn_org, value:a.sessions||0});
byLabel[lbl].value += a.sessions || 0;
});
treemapChart.setOption(ecBase({
tooltip: ecTooltip({formatter: i => `${i.name}<br>Sessions: <b>${(i.value||0).toLocaleString()}</b>`}),
series:[{
type:'treemap',
data: Object.values(byLabel).sort((a,b) => b.value - a.value),
width:'100%', height:'100%',
label:{show:true, fontSize:11, color:'#fff'},
upperLabel:{show:true, height:22, fontSize:12, color:'#fff', fontWeight:'bold',
backgroundColor:'transparent'},
itemStyle:{borderColor:'#111827', borderWidth:2, gapWidth:2},
levels:[
{itemStyle:{borderColor:'#1f2937', borderWidth:3, gapWidth:3},
upperLabel:{show:true}},
{colorSaturation:[0.4,0.8],
itemStyle:{borderColorSaturation:0.5, gapWidth:1, borderWidth:1}},
],
}]
}));
treemapChart.on('click', params => {
if (params.data?.name && params.treePathInfo?.length > 2) window.location.href = '/detections?asn_org=' + encodeURIComponent(params.data.name);
});
}
// ── Country Sunburst ──
const sunburstChart = initChart('chart-sunburst');
if (sunburstChart && countries.length) {
const byCountry = {};
countries.forEach(c => {
if (!byCountry[c.country_code]) byCountry[c.country_code] = {name:c.country_code, children:[]};
byCountry[c.country_code].children.push({
name: c.asn_label || 'unknown',
value: c.sessions || 0,
itemStyle:{color: LABEL_COLORS[c.asn_label] || '#6b7280'},
});
});
sunburstChart.setOption(ecBase({
tooltip: ecTooltip({formatter: i => `${i.name}<br>Sessions: <b>${(i.value||0).toLocaleString()}</b>`}),
series:[{
type:'sunburst',
data: Object.values(byCountry).sort((a,b) => {
const va = a.children.reduce((s,c) => s+c.value,0);
const vb = b.children.reduce((s,c) => s+c.value,0);
return vb - va;
}),
radius:['15%','90%'],
label:{color:'#e5e7eb', fontSize:11, rotate:'radial'},
itemStyle:{borderColor:'#111827', borderWidth:1},
levels:[
{},
{r0:'15%', r:'50%', label:{fontSize:13, fontWeight:'bold'},
itemStyle:{borderWidth:2}},
{r0:'50%', r:'90%', label:{fontSize:10}},
],
}]
}));
sunburstChart.on('click', params => {
if (params.data?.name && params.data.name.length <= 3) window.location.href = '/detections?country_code=' + encodeURIComponent(params.data.name);
});
}
// ── JA4 Fingerprint Table ──
const botMap = {};
botJa4.forEach(b => { botMap[b.ja4] = b.bot_name; });
ja4Rows = ja4Stats.map(j => [
j.ja4,
j.sessions || 0,
j.total_hits || 0,
j.avg_velocity || 0,
j.avg_fuzz || 0,
j.avg_browser_score || 0,
j.asn_label || 'unknown',
botMap[j.ja4] || '',
]);
renderJA4Table();
// ── ASN Detail Table ──
document.getElementById('asn-body').innerHTML = asns
.sort((a,b) => (b.sessions||0) - (a.sessions||0))
.map(a => `<tr>
<td class="text-xs">${fmtASN(a.asn_org)}</td>
<td>${fmtLabel(a.asn_label)}</td>
<td>${fmtCountry(a.country_code)}</td>
<td>${(a.sessions||0).toLocaleString()}</td>
<td>${(a.total_hits||0).toLocaleString()}</td>
<td>${(a.avg_velocity||0).toFixed(3)}</td>
<td>${(a.avg_fuzz||0).toFixed(3)}</td>
</tr>`).join('');
// ── Bot Fingerprints Pie ──
const botPieChart = initChart('chart-botpie');
if (botPieChart && botJa4.length) {
const byBot = {};
botJa4.forEach(b => { byBot[b.bot_name] = (byBot[b.bot_name]||0) + (b.sessions||0); });
botPieChart.setOption(ecBase({
tooltip: ecTooltip({trigger:'item', formatter:'{b}: {c} ({d}%)'}),
series:[{
type:'pie', radius:['35%','75%'], center:['50%','55%'],
label:{color:EC_TEXT, fontSize:11, formatter:'{b}\n{d}%'},
data: Object.entries(byBot)
.map(([name,value],i) => ({name, value, itemStyle:{color:EC_COLORS[i%EC_COLORS.length]}}))
.sort((a,b) => b.value - a.value),
emphasis:{itemStyle:{shadowBlur:10, shadowColor:'rgba(0,0,0,0.5)'}},
}]
}));
}
} catch(e) { console.error('Network load error:', e); }
}
loadAll();
setInterval(loadAll, 60000);
window.addEventListener('resize', () => Object.values(charts).forEach(c => c?.resize()));
</script>
{% endblock %}

View File

@ -3,30 +3,74 @@
{% 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 class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4" id="kpi-grid">
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-red-400"></span><span class="text-xs text-gray-500">Détections 24h</span></div>
<div class="text-2xl font-bold text-red-400" id="kpi-detections"></div>
</div>
<!-- Charts Row -->
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-indigo-400"></span><span class="text-xs text-gray-500">Sessions scorées</span></div>
<div class="text-2xl font-bold text-brand-500" id="kpi-scored"></div>
</div>
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-gray-400"></span><span class="text-xs text-gray-500">Trafic total 24h</span></div>
<div class="text-2xl font-bold text-gray-200" id="kpi-traffic"></div>
</div>
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-yellow-400"></span><span class="text-xs text-gray-500">IPs uniques</span></div>
<div class="text-2xl font-bold text-yellow-400" id="kpi-ips"></div>
</div>
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-orange-400"></span><span class="text-xs text-gray-500">Critical / High</span></div>
<div class="text-2xl font-bold text-orange-400" id="kpi-critical"></div>
</div>
<div class="kpi-card">
<div class="flex items-center gap-2 mb-1"><span class="w-2 h-2 rounded-full bg-green-400"></span><span class="text-xs text-gray-500">Modèles actifs</span></div>
<div class="text-2xl font-bold text-green-400" id="kpi-models"></div>
</div>
</div>
<!-- Charts Row 1: Timeline + Threats -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="lg:col-span-2 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>
<div id="chart-timeline" style="height:280px"></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">Threat levels</h3>
<div id="chart-threats" style="height:280px"></div>
</div>
</div>
<!-- Charts Row 2: ASN Treemap + Country -->
<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>
<h3 class="text-sm font-medium text-gray-400 mb-3">Répartition par ASN</h3>
<div id="chart-asn" style="height:300px"></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">Distribution des threat levels</h3>
<canvas id="chart-threats" height="200"></canvas>
<h3 class="text-sm font-medium text-gray-400 mb-3">Répartition géographique</h3>
<div id="chart-geo" style="height:300px"></div>
</div>
</div>
<!-- Top IPs -->
<!-- Charts Row 3: JA4 Diversity + Bot names -->
<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">Empreintes JA4 (top 15)</h3>
<div id="chart-ja4" style="height:300px"></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">Bots identifiés</h3>
<div id="chart-bots" style="height:300px"></div>
</div>
</div>
<!-- Top IPs table -->
<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">
<table class="data-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>
@ -36,47 +80,168 @@
{% endblock %}
{% block scripts %}
<script>
let timelineChart, threatsChart;
const THREAT_COLORS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',LOW:'#22c55e',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',ANUBIS_DENY:'#dc2626'};
const LABEL_COLORS = {human:'#22c55e',datacenter:'#ef4444',hosting:'#f97316',unknown:'#6b7280'};
let charts = {};
function initChart(id) {
const el = document.getElementById(id);
if (!el) return null;
if (charts[id]) charts[id].dispose();
charts[id] = echarts.init(el);
return charts[id];
}
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'}} } }
const [ov, geo, fp] = await Promise.all([
fetch('/api/overview').then(r=>r.json()),
fetch('/api/geo').then(r=>r.json()),
fetch('/api/fingerprints').then(r=>r.json()),
]);
// KPIs
document.getElementById('kpi-detections').textContent = (ov.detections_24h??0).toLocaleString();
document.getElementById('kpi-scored').textContent = (ov.scored_24h??0).toLocaleString();
document.getElementById('kpi-traffic').textContent = (ov.traffic_24h??0).toLocaleString();
document.getElementById('kpi-ips').textContent = (ov.unique_ips??0).toLocaleString();
document.getElementById('kpi-critical').textContent = ((ov.critical_count??0)+(ov.high_count??0)).toLocaleString();
document.getElementById('kpi-models').textContent = ov.models?.length ?? 0;
// Timeline area chart
if (ov.timeline?.length) {
const ch = initChart('chart-timeline');
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'axis'}),
grid: {left:50,right:20,top:20,bottom:30},
xAxis: {type:'category', data: ov.timeline.map(t=>t.hour?.substring(11,16)||''), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT}},
yAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
series: [{
type:'line', data: ov.timeline.map(t=>t.cnt), smooth:true,
areaStyle:{color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:'rgba(99,102,241,0.4)'},{offset:1,color:'rgba(99,102,241,0.02)'}])},
lineStyle:{color:'#6366f1',width:2}, itemStyle:{color:'#6366f1'}, symbol:'circle', symbolSize:6,
}]
}));
}
// Threats pie
if (ov.threat_distribution?.length) {
const ch = initChart('chart-threats');
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'item', formatter:'{b}: {c} ({d}%)'}),
series: [{
type:'pie', radius:['45%','75%'], center:['50%','55%'],
label:{color:EC_TEXT, fontSize:11},
data: ov.threat_distribution.map(t=>({name:t.threat_level, value:t.cnt, itemStyle:{color:THREAT_COLORS[t.threat_level]||'#6b7280'}})),
emphasis:{itemStyle:{shadowBlur:10,shadowColor:'rgba(0,0,0,0.5)'}},
}]
}));
ch.on('click', params => {
if (params.name) window.location.href = '/detections?threat_level=' + encodeURIComponent(params.name);
});
}
// 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}}}}}
// ASN horizontal bar
if (geo.asns?.length) {
const top = geo.asns.slice(0,12);
const ch = initChart('chart-asn');
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'axis',axisPointer:{type:'shadow'}}),
grid: {left:160,right:30,top:10,bottom:30},
yAxis: {type:'category', data: top.map(a=>a.asn_org).reverse(), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT,fontSize:11,width:140,overflow:'truncate'}},
xAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
series: [{
type:'bar', data: top.map(a=>({value:a.sessions,itemStyle:{color:LABEL_COLORS[a.asn_label]||'#6b7280'}})).reverse(),
barWidth:'60%', label:{show:true,position:'right',color:EC_TEXT,fontSize:11},
}]
}));
ch.on('click', params => {
if (params.name) window.location.href = '/detections?asn_org=' + encodeURIComponent(params.name);
});
}
// Country treemap
if (geo.countries?.length) {
const byCountry = {};
geo.countries.forEach(c => {
if (!byCountry[c.country_code]) byCountry[c.country_code] = {name:c.country_code, value:0, children:[]};
byCountry[c.country_code].value += c.sessions;
byCountry[c.country_code].children.push({name:c.asn_label, value:c.sessions});
});
const ch = initChart('chart-geo');
ch.setOption(ecBase({
tooltip: ecTooltip({formatter:i => `${i.name}: ${i.value} sessions`}),
series: [{
type:'treemap', data: Object.values(byCountry).sort((a,b)=>b.value-a.value).slice(0,20),
width:'100%', height:'100%',
label:{show:true,fontSize:13,color:'#fff',fontWeight:'bold'},
upperLabel:{show:false},
itemStyle:{borderColor:'#111827',borderWidth:2,gapWidth:2},
levels:[
{itemStyle:{borderColor:'#1f2937',borderWidth:3,gapWidth:3},upperLabel:{show:false}},
{colorSaturation:[0.3,0.7],itemStyle:{borderColorSaturation:0.6,gapWidth:1,borderWidth:1}},
],
colorMappingBy:'value',
color: EC_COLORS,
}]
}));
ch.on('click', params => {
if (params.data?.name && params.data.name.length <= 3) window.location.href = '/detections?country_code=' + encodeURIComponent(params.data.name);
});
}
// JA4 bar chart
if (fp.ja4_stats?.length) {
const top15 = fp.ja4_stats.slice(0,15);
const ch = initChart('chart-ja4');
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'axis',axisPointer:{type:'shadow'}}),
grid: {left:50,right:20,top:10,bottom:80},
xAxis: {type:'category', data:top15.map(j=>j.ja4.substring(0,16)+'…'), axisLabel:{color:EC_TEXT,rotate:45,fontSize:10}, axisLine:{lineStyle:{color:EC_GRID}}},
yAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
series: [{
type:'bar', data:top15.map(j=>({value:j.sessions,itemStyle:{color:LABEL_COLORS[j.asn_label]||EC_COLORS[0]}})),
barWidth:'65%',
}]
}));
ch.on('click', params => {
if (params.dataIndex !== undefined) {
const ja4 = fp.ja4_stats[params.dataIndex]?.ja4;
if (ja4) window.location.href = '/detections?ja4=' + encodeURIComponent(ja4);
}
});
}
// Bots pie
if (fp.bot_ja4?.length) {
const ch = initChart('chart-bots');
const byBot = {};
fp.bot_ja4.forEach(b => { byBot[b.bot_name] = (byBot[b.bot_name]||0) + b.sessions; });
ch.setOption(ecBase({
tooltip: ecTooltip({trigger:'item', formatter:'{b}: {c} ({d}%)'}),
series: [{
type:'pie', radius:['35%','70%'], center:['50%','55%'],
label:{color:EC_TEXT,fontSize:11,formatter:'{b}\n{d}%'},
data: Object.entries(byBot).map(([k,v],i)=>({name:k,value:v,itemStyle:{color:EC_COLORS[i%EC_COLORS.length]}}))
.sort((a,b)=>b.value-a.value),
emphasis:{itemStyle:{shadowBlur:10}},
}]
}));
ch.on('click', params => {
if (params.name) window.location.href = '/detections?bot_name=' + encodeURIComponent(params.name);
});
}
// Top IPs table
const tbody = document.getElementById('top-ips-body');
tbody.innerHTML = (d.top_ips||[]).map(ip => `<tr>
document.getElementById('top-ips-body').innerHTML = (ov.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>
<td>${fmtThreatLink(ip.threat_level)}</td><td class="text-xs">${fmtASN(ip.asn_org)}</td><td>${fmtCountry(ip.country_code)}</td>
</tr>`).join('');
} catch(e) { console.error('Overview load error:', e); }
}
loadOverview();
setInterval(loadOverview, 30000);
setInterval(loadOverview, 60000);
window.addEventListener('resize', () => Object.values(charts).forEach(c => c?.resize()));
</script>
{% endblock %}

View File

@ -2,6 +2,17 @@
{% block title %}JA4 SOC — Scores ML{% endblock %}
{% block content %}
<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>
<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>
</div>
<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">
@ -45,9 +56,17 @@
{% block scripts %}
<script>
let sPage=1, sSort='detected_at', sOrder='DESC', sThreat='';
const urlParams = new URLSearchParams(window.location.search);
let sASN=urlParams.get('asn_org')||'',
sCountry=urlParams.get('country_code')||'',
sJA4=urlParams.get('ja4')||'';
if(urlParams.get('threat_level')) sThreat=urlParams.get('threat_level');
async function loadScores() {
const params = new URLSearchParams({page:sPage,per_page:50,sort:sSort,order:sOrder});
if(sThreat) params.set('threat_level',sThreat);
if(sASN) params.set('asn_org',sASN);
if(sCountry) params.set('country_code',sCountry);
if(sJA4) params.set('ja4',sJA4);
try {
const r = await fetch('/api/scores?'+params);
const d = await r.json();
@ -59,12 +78,12 @@ async function loadScores() {
<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>${fmtThreatLink(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 font-mono max-w-[100px] truncate">${fmtJA4(row.ja4)}</td>
<td class="text-xs max-w-[120px] truncate">${row.host||''}</td>
<td>${row.hits||0}</td>
<td>${row.country_code||''}</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>';
const total = d.total||0;
document.getElementById('scores-info').textContent = `${total} résultats — page ${sPage}/${Math.max(1,Math.ceil(total/50))}`;
@ -85,5 +104,43 @@ document.querySelectorAll('[data-filter]').forEach(btn => btn.onclick = () => {
sThreat = btn.dataset.filter; sPage=1; loadScores();
});
loadScores();
// Score distribution charts
async function loadScoreSummary() {
try {
const r = await fetch('/api/scores?per_page=500');
const d = await r.json();
const rows = d.data || [];
// Histogram of anomaly_score
const buckets = {};
rows.forEach(row => {
const b = Math.round((row.anomaly_score||0)*10)/10;
buckets[b] = (buckets[b]||0)+1;
});
const labels = Object.keys(buckets).sort((a,b)=>a-b);
const ch1 = echarts.init(document.getElementById('score-dist-chart'));
ch1.setOption(ecBase({
tooltip:ecTooltip({trigger:'axis'}),
grid:{left:40,right:15,top:10,bottom:25},
xAxis:{type:'category',data:labels,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:[{type:'bar',data:labels.map(l=>buckets[l]),barWidth:'70%',
itemStyle:{color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:'#6366f1'},{offset:1,color:'#4338ca'}])}}]
}));
// Scatter AE vs XGB
const scatterData = rows.filter(r=>r.ae_recon_error>0||r.xgb_prob>0).map(r=>[r.ae_recon_error||0,r.xgb_prob||0]);
if (scatterData.length) {
const ch2 = echarts.init(document.getElementById('score-scatter-chart'));
ch2.setOption(ecBase({
tooltip:ecTooltip({trigger:'item',formatter:p=>`AE: ${p.data[0].toFixed(4)}<br>XGB: ${p.data[1].toFixed(4)}`}),
grid:{left:40,right:15,top:10,bottom:25},
xAxis:{name:'AE Error',nameTextStyle:{color:EC_TEXT},type:'value',splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}},axisLabel:{color:EC_TEXT}},
yAxis:{name:'XGB Prob',nameTextStyle:{color:EC_TEXT},type:'value',splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}},axisLabel:{color:EC_TEXT}},
series:[{type:'scatter',data:scatterData,symbolSize:6,itemStyle:{color:'rgba(99,102,241,0.6)'}}]
}));
}
} catch(e) { console.error(e); }
}
loadScoreSummary();
</script>
{% endblock %}

View File

@ -2,6 +2,21 @@
{% block title %}JA4 SOC — Trafic HTTP{% endblock %}
{% block content %}
<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>
<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>
<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>
</div>
<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">
@ -50,8 +65,8 @@ async function loadTraffic() {
<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>
<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>';
const total=d.total||0;
document.getElementById('traffic-info').textContent=`${total} logs — page ${tPage}/${Math.max(1,Math.ceil(total/100))}`;
@ -66,5 +81,47 @@ document.getElementById('next-btn').onclick=()=>{tPage++;loadTraffic();};
el.addEventListener(el.tagName==='SELECT'?'change':'input',()=>{tPage=1;loadTraffic();});
});
loadTraffic();
// Traffic summary charts
async function loadTrafficSummary() {
try {
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 = {};
rows.forEach(row => {
methods[row.method] = (methods[row.method]||0)+1;
const ua = (row.header_user_agent||'').substring(0,30) || '(empty)';
uas[ua] = (uas[ua]||0)+1;
const p = (row.path||'/').split('?')[0];
paths[p] = (paths[p]||0)+1;
});
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'));
ch2.setOption(ecBase({tooltip:ecTooltip({trigger:'axis'}),grid:{left:10,right:40,top:5,bottom:5,containLabel:true},
yAxis:{type:'category',data:topUA.map(r=>r[0]).reverse(),axisLabel:{color:EC_TEXT,fontSize:9,width:120,overflow:'truncate'},axisLine:{show:false}},
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'));
ch3.setOption(ecBase({tooltip:ecTooltip({trigger:'axis'}),grid:{left:10,right:40,top:5,bottom:5,containLabel:true},
yAxis:{type:'category',data:topPath.map(r=>r[0]).reverse(),axisLabel:{color:EC_TEXT,fontSize:9,width:120,overflow:'truncate'},axisLine:{show:false}},
xAxis:{type:'value',show:false},
series:[{type:'bar',data:topPath.map(r=>r[1]).reverse(),barWidth:'60%',itemStyle:{color:'#14b8a6'},label:{show:true,position:'right',color:EC_TEXT,fontSize:10}}]}));
}
} catch(e) { console.error(e); }
}
loadTrafficSummary();
</script>
{% endblock %}