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:
277
services/dashboard/backend/templates/network.html
Normal file
277
services/dashboard/backend/templates/network.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user