- models.html: Full rewrite — 6 KPIs, scoring volume timeline, anomaly rate chart, threat breakdown per model, enhanced model cards with validation gate - classify.html: SOC workflow — suggested unclassified IPs, quick-classify buttons, classification stats pie, pre-fill from URL params - traffic.html: Clickable rows → ip_detail, column sorting, status column, search filter, doc tooltips on all chart sections - scores.html: Search input, clickable rows → ip_detail, LEGITIMATE_BROWSER filter button, doc tooltips on distribution + scatter charts - ip_detail.html: Resource cascade section (headless browser detection), status column in HTTP logs table - detections.html: Doc tooltips on threat/reason/ASN chart sections - features.html: Doc tooltips on radar/importance/scatter sections - api.py: 4 new endpoints — /api/models/timeline, /api/models/threats, /api/classify/stats, /api/classify/suggested. Traffic API: status + search. 46 routes total. All tests pass (dashboard + bot-detector 36/36). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
267 lines
17 KiB
HTML
267 lines
17 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}JA4 SOC — Modèles{% endblock %}
|
|
{% block page_title %}
|
|
Modèles ML
|
|
<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
|
<h4>Monitoring des modèles ML</h4>
|
|
<p>Ensemble triple-voix : Extended Isolation Forest (EIF) + Autoencoder (AE) + XGBoost supervisé.</p>
|
|
<p><strong>Cycle :</strong> Toutes les 30 min, le bot-detector ré-entraîne si une dérive est détectée (≥95% features). Les anciens modèles restent en cache.</p>
|
|
<p><strong>Workflow :</strong> Surveillez le volume de scoring, le taux d'anomalie et la santé des modèles. Un taux d'anomalie > 10% peut indiquer une attaque ou un modèle dégradé.</p>
|
|
<p class="doc-source">Source : ml_all_scores (7j), /data/models/*.json</p>
|
|
</div></span>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<div class="space-y-4">
|
|
<!-- KPIs -->
|
|
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Sessions scorées (7j)</div><div class="text-xl font-bold text-brand-500" id="kpi-total">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Modèles actifs</div><div class="text-xl font-bold text-green-400" id="kpi-models">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Anomalies détectées</div><div class="text-xl font-bold text-red-400" id="kpi-anomalies">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Taux d'anomalie</div><div class="text-xl font-bold text-orange-400" id="kpi-rate">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Dernier scoring</div><div class="text-sm font-medium text-gray-200" id="kpi-last">—</div></div>
|
|
<div class="kpi-card"><div class="text-[11px] text-gray-500 mb-1">Dernier entraînement</div><div class="text-sm font-medium text-yellow-400" id="kpi-train">—</div></div>
|
|
</div>
|
|
|
|
<!-- Scoring volume timeline + Threat breakdown -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
<div class="section-card lg:col-span-2">
|
|
<div class="section-header"><span class="section-title">Volume de scoring
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
|
<h4>Timeline de scoring</h4>
|
|
<p>Nombre de sessions scorées par heure et par modèle. La courbe orange montre le score moyen d'anomalie.</p>
|
|
<p><strong>Interprétation :</strong> Un creux soudain indique un problème de pipeline. Un pic de score moyen = vague d'attaque.</p>
|
|
<p class="doc-source">Source : ml_all_scores GROUP BY hour, model_name</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="section-body"><div id="timeline-chart" style="height:260px"></div></div>
|
|
</div>
|
|
<div class="section-card">
|
|
<div class="section-header"><span class="section-title">Répartition menaces
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
|
<h4>Menaces par modèle</h4>
|
|
<p>Répartition des niveaux de menace (NORMAL, HIGH, CRITICAL, KNOWN_BOT, LEGITIMATE_BROWSER) par modèle.</p>
|
|
<p class="doc-source">Source : ml_all_scores GROUP BY model_name, threat_level</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="section-body"><div id="threats-chart" style="height:260px"></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Score avg timeline (anomaly rate) -->
|
|
<div class="section-card">
|
|
<div class="section-header"><span class="section-title">Taux d'anomalie horaire
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
|
<h4>Anomaly rate over time</h4>
|
|
<p>Pourcentage de sessions classées HIGH/CRITICAL par heure. Les barres montrent le volume, la ligne le taux.</p>
|
|
<p><strong>Seuil d'alerte :</strong> Un taux > 10% prolongé mérite investigation.</p>
|
|
<p class="doc-source">Source : ml_all_scores</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="section-body"><div id="rate-chart" style="height:200px"></div></div>
|
|
</div>
|
|
|
|
<!-- Scoring stats table -->
|
|
<div class="section-card overflow-hidden">
|
|
<div class="section-header"><span class="section-title">Statistiques de scoring (7 jours)
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
|
<h4>Résumé par modèle</h4>
|
|
<p>Sessions scorées, période active et dernière activité pour chaque modèle. Complet = L3→L7 corrélé, Applicatif = L7 seul.</p>
|
|
<p class="doc-source">Source : ml_all_scores GROUP BY model_name</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div class="overflow-x-auto">
|
|
<table class="data-table"><thead><tr>
|
|
<th>Modèle</th><th>Sessions scorées</th><th>Premier scoring</th><th>Dernier scoring</th>
|
|
</tr></thead><tbody id="scoring-body"></tbody></table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Model metadata cards -->
|
|
<div class="section-card overflow-hidden">
|
|
<div class="section-header"><span class="section-title">Versions des modèles
|
|
<span class="relative inline-block"><button onclick="docToggle(this)" class="doc-btn">?</button><div class="doc-panel">
|
|
<h4>Métadonnées des modèles</h4>
|
|
<p>Chaque fichier .json dans /data/models/ décrit un modèle entraîné : version, algorithme, paramètres, métriques de validation.</p>
|
|
<p><strong>Gate de validation :</strong> Un modèle n'est utilisé que si val_anomaly_rate < 5% et val_mean_score est raisonnable.</p>
|
|
<p class="doc-source">Source : /data/models/*.json</p>
|
|
</div></span>
|
|
</span></div>
|
|
<div id="model-cards" class="p-5 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<span class="text-sm text-gray-500">Chargement...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
{% block scripts %}
|
|
<script>
|
|
let mCharts = {};
|
|
function initC(id) { const el=document.getElementById(id); if(!el) return null; if(mCharts[id]) mCharts[id].dispose(); mCharts[id]=echarts.init(el); return mCharts[id]; }
|
|
|
|
async function loadModels() {
|
|
try {
|
|
const [md, tl, th] = await Promise.all([
|
|
fetch('/api/models').then(r=>r.json()),
|
|
fetch('/api/models/timeline').then(r=>r.json()),
|
|
fetch('/api/models/threats').then(r=>r.json()),
|
|
]);
|
|
|
|
// ── KPIs ──
|
|
const stats = md.scoring_stats || [];
|
|
const totalScored = stats.reduce((s,r)=>s+(r.scored||0),0);
|
|
document.getElementById('kpi-total').textContent = fmtNum(totalScored);
|
|
document.getElementById('kpi-models').textContent = stats.length;
|
|
const lastSeen = stats.map(r=>r.last_seen||'').sort().pop() || '—';
|
|
document.getElementById('kpi-last').textContent = lastSeen.substring(0,16);
|
|
if (md.models?.length) {
|
|
const latest = md.models[md.models.length-1];
|
|
document.getElementById('kpi-train').textContent = (latest.trained_at||'').substring(0,16);
|
|
}
|
|
|
|
// ── Timeline chart ──
|
|
const tlRows = tl.timeline || [];
|
|
if (tlRows.length) {
|
|
const hours = [...new Set(tlRows.map(r=>r.hour))].sort();
|
|
const models = [...new Set(tlRows.map(r=>r.model_name))];
|
|
const MODEL_COLORS = {'Complet':'#6366f1','Applicatif':'#14b8a6','Complet_24h':'#8b5cf6','Applicatif_24h':'#06b6d4'};
|
|
const byHourModel = {};
|
|
let totalAnom = 0;
|
|
tlRows.forEach(r => { byHourModel[r.hour+'|'+r.model_name] = r; totalAnom += (r.anomalies||0); });
|
|
document.getElementById('kpi-anomalies').textContent = fmtNum(totalAnom);
|
|
document.getElementById('kpi-rate').textContent = totalScored>0 ? (totalAnom/totalScored*100).toFixed(1)+'%' : '—';
|
|
|
|
const ch = initC('timeline-chart');
|
|
ch.setOption(ecBase({
|
|
tooltip: ecTooltip({trigger:'axis'}),
|
|
legend: {data:models, bottom:0, textStyle:{color:EC_TEXT,fontSize:10}},
|
|
grid: {left:50,right:20,top:15,bottom:35},
|
|
xAxis: {type:'category', data:hours.map(h=>(h||'').substring(11,16)), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT,fontSize:10}},
|
|
yAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
|
|
series: models.map(m => ({
|
|
name:m, type:'bar', stack:'vol', barWidth:'70%',
|
|
data: hours.map(h => (byHourModel[h+'|'+m]||{}).cnt || 0),
|
|
itemStyle:{color:MODEL_COLORS[m]||'#6b7280'},
|
|
}))
|
|
}));
|
|
}
|
|
|
|
// ── Anomaly rate chart ──
|
|
if (tlRows.length) {
|
|
const hours = [...new Set(tlRows.map(r=>r.hour))].sort();
|
|
const hourAgg = {};
|
|
tlRows.forEach(r => {
|
|
if (!hourAgg[r.hour]) hourAgg[r.hour] = {total:0, anom:0};
|
|
hourAgg[r.hour].total += r.cnt||0;
|
|
hourAgg[r.hour].anom += r.anomalies||0;
|
|
});
|
|
const ch = initC('rate-chart');
|
|
ch.setOption(ecBase({
|
|
tooltip: ecTooltip({trigger:'axis'}),
|
|
grid: {left:50,right:50,top:15,bottom:25},
|
|
xAxis: {type:'category', data:hours.map(h=>(h||'').substring(11,16)), axisLine:{lineStyle:{color:EC_GRID}}, axisLabel:{color:EC_TEXT,fontSize:10}},
|
|
yAxis: [
|
|
{type:'value', name:'Sessions', nameTextStyle:{color:EC_TEXT}, splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
|
|
{type:'value', name:'Taux %', nameTextStyle:{color:EC_TEXT}, max:100, splitLine:{show:false}, axisLabel:{color:EC_TEXT,formatter:'{value}%'}},
|
|
],
|
|
series: [
|
|
{name:'Sessions', type:'bar', yAxisIndex:0, barWidth:'60%',
|
|
data:hours.map(h=>(hourAgg[h]||{}).total||0),
|
|
itemStyle:{color:'rgba(99,102,241,0.3)'}},
|
|
{name:'Taux anomalie', type:'line', yAxisIndex:1, smooth:true,
|
|
data:hours.map(h=>{const a=hourAgg[h]; return a&&a.total>0 ? +(a.anom/a.total*100).toFixed(1) : 0;}),
|
|
lineStyle:{color:'#f97316',width:2}, itemStyle:{color:'#f97316'}, symbol:'circle', symbolSize:3,
|
|
areaStyle:{color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:'rgba(249,115,22,0.15)'},{offset:1,color:'rgba(249,115,22,0)'}])}},
|
|
]
|
|
}));
|
|
}
|
|
|
|
// ── Threats chart ──
|
|
const thRows = th.threats || [];
|
|
if (thRows.length) {
|
|
const models = [...new Set(thRows.map(r=>r.model_name))];
|
|
const levels = [...new Set(thRows.map(r=>r.threat_level))];
|
|
const TCOLS = {CRITICAL:'#ef4444',HIGH:'#f97316',MEDIUM:'#eab308',NORMAL:'#6b7280',KNOWN_BOT:'#3b82f6',LEGITIMATE_BROWSER:'#22c55e',ANUBIS_DENY:'#a855f7'};
|
|
const byMT = {};
|
|
thRows.forEach(r => { byMT[r.model_name+'|'+r.threat_level] = r.cnt; });
|
|
const ch = initC('threats-chart');
|
|
ch.setOption(ecBase({
|
|
tooltip: ecTooltip({trigger:'axis'}),
|
|
legend: {data:levels, bottom:0, textStyle:{color:EC_TEXT,fontSize:9}},
|
|
grid: {left:15,right:15,top:15,bottom:50},
|
|
xAxis: {type:'category', data:models, axisLabel:{color:EC_TEXT,fontSize:10}, axisLine:{lineStyle:{color:EC_GRID}}},
|
|
yAxis: {type:'value', splitLine:{lineStyle:{color:EC_GRID,type:'dashed'}}, axisLabel:{color:EC_TEXT}},
|
|
series: levels.map(l => ({
|
|
name:l, type:'bar', stack:'t', barWidth:'50%',
|
|
data:models.map(m => byMT[m+'|'+l]||0),
|
|
itemStyle:{color:TCOLS[l]||'#6b7280'},
|
|
}))
|
|
}));
|
|
}
|
|
|
|
// ── Scoring stats table ──
|
|
document.getElementById('scoring-body').innerHTML = stats.map(row => `<tr>
|
|
<td class="font-medium text-gray-200">${escapeHtml(row.model_name||'')}</td>
|
|
<td class="font-mono">${fmtNum(row.scored||0)}</td>
|
|
<td class="text-xs text-gray-400">${(row.first_seen||'').substring(0,16)}</td>
|
|
<td class="text-xs text-gray-400">${(row.last_seen||'').substring(0,16)}</td>
|
|
</tr>`).join('') || '<tr><td colspan="4" class="text-center text-gray-500 py-4">Aucun scoring récent</td></tr>';
|
|
|
|
// ── Model cards ──
|
|
const cards = document.getElementById('model-cards');
|
|
if (md.models?.length) {
|
|
cards.innerHTML = md.models.map(m => {
|
|
const valRate = m.validation?.val_anomaly_rate;
|
|
const valBadge = valRate != null
|
|
? (valRate > 0.05 ? 'badge-high' : valRate > 0.02 ? 'badge-medium' : 'badge-low')
|
|
: 'badge-normal';
|
|
return `
|
|
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors">
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<span class="text-sm font-semibold text-white">${escapeHtml(m.model_name||'?')}</span>
|
|
<span class="badge badge-low text-[10px]">${escapeHtml(m.algorithm||'?')}</span>
|
|
${m.autoencoder ? '<span class="badge badge-medium text-[10px]">+AE</span>' : ''}
|
|
<span class="text-[10px] text-gray-500 ml-auto">v${escapeHtml(m.version_id||'?')}</span>
|
|
</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
|
<div class="bg-gray-900/50 rounded p-2">
|
|
<div class="text-[9px] text-gray-500 mb-0.5">Entraîné</div>
|
|
<div class="text-gray-300 font-mono">${(m.trained_at||'?').substring(0,16)}</div>
|
|
</div>
|
|
<div class="bg-gray-900/50 rounded p-2">
|
|
<div class="text-[9px] text-gray-500 mb-0.5">Échantillons</div>
|
|
<div class="text-gray-300 font-mono">${fmtNum(m.human_samples||0)}</div>
|
|
</div>
|
|
<div class="bg-gray-900/50 rounded p-2">
|
|
<div class="text-[9px] text-gray-500 mb-0.5">Contamination</div>
|
|
<div class="text-gray-300 font-mono">${m.contamination||'?'}</div>
|
|
</div>
|
|
<div class="bg-gray-900/50 rounded p-2">
|
|
<div class="text-[9px] text-gray-500 mb-0.5">Seuil</div>
|
|
<div class="text-gray-300 font-mono">${typeof m.threshold==='number'?m.threshold.toFixed(4):m.threshold||'?'}</div>
|
|
</div>
|
|
</div>
|
|
${m.validation ? `
|
|
<div class="mt-3 pt-3 border-t border-gray-700">
|
|
<div class="text-[10px] text-gray-500 mb-2 font-medium">Validation Gate</div>
|
|
<div class="grid grid-cols-4 gap-2 text-xs">
|
|
<div><span class="text-gray-500">Taux anomalie :</span> <span class="badge ${valBadge} text-[10px]">${(valRate*100).toFixed(1)}%</span></div>
|
|
<div><span class="text-gray-500">Score moyen :</span> <span class="text-gray-300">${m.validation.val_mean_score?.toFixed(4)||'?'}</span></div>
|
|
<div><span class="text-gray-500">Train :</span> <span class="text-gray-300">${fmtNum(m.validation.train_size||0)}</span></div>
|
|
<div><span class="text-gray-500">Val :</span> <span class="text-gray-300">${fmtNum(m.validation.val_size||0)}</span></div>
|
|
</div>
|
|
</div>` : ''}
|
|
${m.features_used ? `
|
|
<div class="mt-2 text-[10px] text-gray-500">${m.features_used} features · ${m.features_pruned||0} élaguées</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
} else {
|
|
cards.innerHTML = '<span class="text-sm text-gray-500">Aucun fichier de métadonnées trouvé</span>';
|
|
}
|
|
} catch(e) { console.error('models load error:', e); }
|
|
}
|
|
|
|
loadModels();
|
|
setInterval(loadModels, 60000);
|
|
window.addEventListener('resize', () => Object.values(mCharts).forEach(c=>c?.resize()));
|
|
</script>
|
|
{% endblock %}
|