feat(dashboard): SOC workflow overhaul — sidebar nav, doc tooltips, full-width layout
- base.html: collapsible sidebar navigation, doc tooltip system, JS helpers (fmtNum, fmtPct, fmtDuration, ecGrid, buildTable, docHTML) - overview.html: SOC command center with stacked timeline, live alerts, campaigns panel, browser donut, 6 KPIs - detections.html: threat color dots, raw score column, click-to-navigate rows - network.html: JA4 rotation, brute-force, persistent threats tables, 6 KPIs - ip_detail.html: ASN/country KPIs, AE/XGB/campaign columns, enriched features - scores/traffic/features/models/classify: page_title blocks + doc tooltips - api.py: 9 new endpoints (campaigns, brute-force, ja4-rotation, recurrence, cascade, alerts, timeline-detail, ua-rotation) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -16,28 +16,30 @@
|
||||
extend: {
|
||||
colors: {
|
||||
brand: { 50:'#eef2ff',100:'#e0e7ff',500:'#6366f1',600:'#4f46e5',700:'#4338ca',900:'#312e81' },
|
||||
surface: { 800:'#1e293b', 900:'#0f172a', 950:'#020617' },
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
fontFamily: { sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'] },
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
|
||||
body { font-family: 'Inter', system-ui, sans-serif; }
|
||||
/* ── Threat badges ── */
|
||||
.threat-critical { color: #ef4444; font-weight: 700; }
|
||||
.threat-high { color: #f97316; font-weight: 600; }
|
||||
.threat-medium { color: #eab308; }
|
||||
.threat-low { color: #22c55e; }
|
||||
.threat-normal { color: #6b7280; }
|
||||
.kpi-card { @apply bg-gray-800 rounded-xl p-5 border border-gray-700; }
|
||||
/* ── KPI cards ── */
|
||||
.kpi-card { @apply bg-gray-900/80 rounded-xl p-4 border border-gray-800 backdrop-blur; }
|
||||
/* ── Data table ── */
|
||||
.data-table { @apply w-full text-sm text-left; }
|
||||
.data-table th { @apply px-4 py-3 bg-gray-800 text-gray-300 font-medium border-b border-gray-700 sticky top-0; }
|
||||
.data-table td { @apply px-4 py-2.5 border-b border-gray-800 text-gray-300; }
|
||||
.data-table tbody tr:hover { @apply bg-gray-800/50; }
|
||||
.nav-link { @apply px-4 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors text-sm font-medium; }
|
||||
.nav-link.active { @apply bg-brand-600 text-white; }
|
||||
.data-table th { @apply px-3 py-2.5 bg-gray-900 text-gray-400 font-medium border-b border-gray-800 sticky top-0 text-xs uppercase tracking-wider; }
|
||||
.data-table td { @apply px-3 py-2 border-b border-gray-800/50 text-gray-300; }
|
||||
.data-table tbody tr:hover { @apply bg-gray-800/40; }
|
||||
.data-table tbody tr { @apply cursor-pointer transition-colors; }
|
||||
/* ── Badges ── */
|
||||
.badge { @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium; }
|
||||
.badge-critical { @apply bg-red-500/20 text-red-400; }
|
||||
.badge-high { @apply bg-orange-500/20 text-orange-400; }
|
||||
@ -45,114 +47,203 @@
|
||||
.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; }
|
||||
/* ── Filter buttons ── */
|
||||
.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; }
|
||||
/* ── Section card ── */
|
||||
.section-card { @apply bg-gray-900/80 rounded-xl border border-gray-800 backdrop-blur; }
|
||||
.section-header { @apply flex items-center justify-between px-5 py-3 border-b border-gray-800; }
|
||||
.section-title { @apply text-sm font-semibold text-gray-200 flex items-center gap-2; }
|
||||
.section-body { @apply p-5; }
|
||||
/* ── Sidebar ── */
|
||||
.sidebar { width: 220px; transition: width 0.2s ease; }
|
||||
.sidebar.collapsed { width: 56px; }
|
||||
.sidebar.collapsed .nav-text { display: none; }
|
||||
.sidebar.collapsed .nav-group-title { display: none; }
|
||||
.sidebar.collapsed .sidebar-logo-text { display: none; }
|
||||
.nav-item { @apply flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800/60 transition-colors text-sm cursor-pointer; }
|
||||
.nav-item.active { @apply bg-brand-600/20 text-brand-400 border-l-2 border-brand-500; }
|
||||
.nav-group-title { @apply text-[10px] uppercase tracking-widest text-gray-600 px-3 pt-4 pb-1; }
|
||||
/* ── Doc tooltips ── */
|
||||
.doc-btn { @apply inline-flex items-center justify-center w-5 h-5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-colors cursor-help text-xs; }
|
||||
.doc-panel { @apply hidden absolute z-50 w-80 p-4 bg-gray-800 border border-gray-700 rounded-xl shadow-2xl text-xs text-gray-300 leading-relaxed; }
|
||||
.doc-panel.show { @apply block; }
|
||||
.doc-panel h4 { @apply text-white font-semibold text-sm mb-2; }
|
||||
.doc-panel p { @apply mb-2; }
|
||||
.doc-panel .doc-source { @apply text-gray-500 italic mt-2 pt-2 border-t border-gray-700; }
|
||||
/* ── Animations ── */
|
||||
@keyframes fadeUp { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
|
||||
.animate-in { animation: fadeUp 0.3s ease-out both; }
|
||||
.kpi-card { animation: fadeUp 0.3s 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;
|
||||
}
|
||||
.kpi-card:nth-child(2) { animation-delay: 40ms; }
|
||||
.kpi-card:nth-child(3) { animation-delay: 80ms; }
|
||||
.kpi-card:nth-child(4) { animation-delay: 120ms; }
|
||||
.kpi-card:nth-child(5) { animation-delay: 160ms; }
|
||||
.kpi-card:nth-child(6) { animation-delay: 200ms; }
|
||||
@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; }
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #4b5563; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-200 min-h-screen">
|
||||
<!-- Top Nav -->
|
||||
<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>
|
||||
<span class="text-white font-semibold hidden sm:inline">JA4 SOC</span>
|
||||
<body class="bg-gray-950 text-gray-200 min-h-screen flex">
|
||||
|
||||
<!-- ═══ Sidebar Navigation ═══ -->
|
||||
<aside id="sidebar" class="sidebar fixed top-0 left-0 h-screen bg-gray-950 border-r border-gray-800 flex flex-col z-50 overflow-hidden">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-2 px-3 h-14 border-b border-gray-800 shrink-0">
|
||||
<button onclick="toggleSidebar()" class="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-sm hover:bg-brand-500 transition-colors shrink-0">J4</button>
|
||||
<span class="sidebar-logo-text text-white font-semibold text-sm whitespace-nowrap">JA4 SOC</span>
|
||||
</div>
|
||||
<!-- Nav groups -->
|
||||
<nav class="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
|
||||
<div class="nav-group-title">Surveillance</div>
|
||||
<a href="/" class="nav-item {% if active_page == 'overview' %}active{% endif %}" title="Vue d'ensemble">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
|
||||
<span class="nav-text">Overview</span>
|
||||
</a>
|
||||
<a href="/" class="nav-link {% if active_page == 'overview' %}active{% endif %}">Overview</a>
|
||||
<a href="/detections" class="nav-link {% if active_page == 'detections' %}active{% endif %}">Détections</a>
|
||||
<a href="/scores" class="nav-link {% if active_page == 'scores' %}active{% endif %}">Scores</a>
|
||||
<a href="/traffic" class="nav-link {% if active_page == 'traffic' %}active{% endif %}">Trafic</a>
|
||||
<a href="/features" class="nav-link {% if active_page == 'features' %}active{% endif %}">Features</a>
|
||||
<a href="/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="flex items-center gap-2 text-xs text-gray-500">
|
||||
<a href="/detections" class="nav-item {% if active_page == 'detections' %}active{% endif %}" title="Détections d'anomalies">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
<span class="nav-text">Détections</span>
|
||||
</a>
|
||||
<a href="/scores" class="nav-item {% if active_page == 'scores' %}active{% endif %}" title="Scores ML">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
<span class="nav-text">Scores</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-group-title">Investigation</div>
|
||||
<a href="/traffic" class="nav-item {% if active_page == 'traffic' %}active{% endif %}" title="Logs HTTP bruts">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7c-2 0-3 1-3 3z"/><path stroke-linecap="round" stroke-width="2" d="M9 12h6M9 8h6M9 16h3"/></svg>
|
||||
<span class="nav-text">Trafic</span>
|
||||
</a>
|
||||
<a href="/network" class="nav-item {% if active_page == 'network' %}active{% endif %}" title="Analyse réseau">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
|
||||
<span class="nav-text">Réseau</span>
|
||||
</a>
|
||||
<a href="/features" class="nav-item {% if active_page == 'features' %}active{% endif %}" title="Features ML">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
|
||||
<span class="nav-text">Features</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-group-title">Opérations</div>
|
||||
<a href="/models" class="nav-item {% if active_page == 'models' %}active{% endif %}" title="Modèles ML">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
<span class="nav-text">Modèles</span>
|
||||
</a>
|
||||
<a href="/classify" class="nav-item {% if active_page == 'classify' %}active{% endif %}" title="Classification SOC">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||
<span class="nav-text">Classifier</span>
|
||||
</a>
|
||||
</nav>
|
||||
<!-- Footer -->
|
||||
<div class="px-3 py-3 border-t border-gray-800 shrink-0">
|
||||
<span class="flex items-center gap-2 text-[10px] text-gray-500">
|
||||
<span class="live-dot"></span>
|
||||
<span id="clock"></span>
|
||||
<span id="clock" class="nav-text"></span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Content -->
|
||||
<main class="max-w-[1600px] mx-auto px-4 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</aside>
|
||||
|
||||
<!-- ═══ Main Content ═══ -->
|
||||
<div id="main-wrap" class="flex-1 min-h-screen" style="margin-left:220px; transition: margin-left 0.2s ease;">
|
||||
<!-- Page header -->
|
||||
<header class="sticky top-0 z-40 bg-gray-950/80 backdrop-blur border-b border-gray-800">
|
||||
<div class="flex items-center h-12 px-6">
|
||||
<h1 class="text-base font-semibold text-gray-100">{% block page_title %}{% endblock %}</h1>
|
||||
<div class="flex-1"></div>
|
||||
{% block header_actions %}{% endblock %}
|
||||
</div>
|
||||
</header>
|
||||
<main class="px-6 py-5">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Sidebar toggle ──
|
||||
function toggleSidebar() {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const mw = document.getElementById('main-wrap');
|
||||
sb.classList.toggle('collapsed');
|
||||
mw.style.marginLeft = sb.classList.contains('collapsed') ? '56px' : '220px';
|
||||
}
|
||||
|
||||
// ── Clock ──
|
||||
function updateClock() {
|
||||
document.getElementById('clock').textContent = new Date().toLocaleString('fr-FR');
|
||||
const el = document.getElementById('clock');
|
||||
if (el) el.textContent = new Date().toLocaleString('fr-FR');
|
||||
}
|
||||
updateClock(); setInterval(updateClock, 1000);
|
||||
|
||||
// ── Existing helpers ──
|
||||
// ── Doc tooltip system ──
|
||||
function docToggle(btn) {
|
||||
const panel = btn.nextElementSibling;
|
||||
document.querySelectorAll('.doc-panel.show').forEach(p => { if (p !== panel) p.classList.remove('show'); });
|
||||
panel.classList.toggle('show');
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('.doc-btn') && !e.target.closest('.doc-panel'))
|
||||
document.querySelectorAll('.doc-panel.show').forEach(p => p.classList.remove('show'));
|
||||
});
|
||||
|
||||
// ── Number formatting ──
|
||||
function fmtNum(n) {
|
||||
if (n == null) return '—';
|
||||
n = Number(n);
|
||||
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n/1000).toFixed(1) + 'K';
|
||||
return n.toLocaleString('fr-FR');
|
||||
}
|
||||
function fmtPct(n) { return n == null ? '—' : (Number(n)*100).toFixed(1)+'%'; }
|
||||
|
||||
// ── Threat helpers ──
|
||||
function threatBadge(level) {
|
||||
const map = {
|
||||
'CRITICAL':'badge-critical','HIGH':'badge-high','MEDIUM':'badge-medium',
|
||||
'LOW':'badge-low','NORMAL':'badge-normal','KNOWN_BOT':'badge-known','ANUBIS_DENY':'badge-critical'
|
||||
'LOW':'badge-low','NORMAL':'badge-normal','KNOWN_BOT':'badge-known',
|
||||
'ANUBIS_DENY':'badge-critical','LEGITIMATE_BROWSER':'badge-low'
|
||||
};
|
||||
return `<span class="badge ${map[level]||'badge-normal'}">${level}</span>`;
|
||||
return `<span class="badge ${map[level]||'badge-normal'}">${escapeHtml(level||'')}</span>`;
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
d.textContent = String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Link formatters (all sanitized) ──
|
||||
function fmtIP(ip) {
|
||||
if (!ip) return '';
|
||||
let s = String(ip).replace('::ffff:','');
|
||||
return `<a href="/ip/${encodeURIComponent(s)}" class="text-brand-500 hover:underline">${escapeHtml(s)}</a>`;
|
||||
return `<a href="/ip/${encodeURIComponent(s)}" class="text-brand-500 hover:underline font-mono text-xs">${escapeHtml(s)}</a>`;
|
||||
}
|
||||
function fmtScore(v) {
|
||||
let n = parseFloat(v);
|
||||
if (isNaN(n)) return '-';
|
||||
if (isNaN(n)) return '—';
|
||||
let color = n > 0.7 ? 'text-red-400' : n > 0.4 ? 'text-orange-400' : n > 0.1 ? 'text-yellow-400' : 'text-green-400';
|
||||
return `<span class="${color}">${n.toFixed(4)}</span>`;
|
||||
return `<span class="${color} font-mono">${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">${escapeHtml(org)}</a>`;
|
||||
return `<a href="/network?asn_org=${encodeURIComponent(org)}" class="text-blue-400 hover:underline cursor-pointer">${escapeHtml(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':'🇮🇳'};
|
||||
const flags = {'FR':'🇫🇷','DE':'🇩🇪','NL':'🇳🇱','GB':'🇬🇧','ES':'🇪🇸','US':'🇺🇸','RU':'🇷🇺','IT':'🇮🇹','JP':'🇯🇵','CN':'🇨🇳','KR':'🇰🇷','BR':'🇧🇷','AU':'🇦🇺','CA':'🇨🇦','IN':'🇮🇳','SG':'🇸🇬','SE':'🇸🇪','FI':'🇫🇮','IE':'🇮🇪','CH':'🇨🇭'};
|
||||
return `<a href="/detections?country_code=${encodeURIComponent(cc)}" class="hover:underline cursor-pointer">${flags[cc]||'🏳️'} ${escapeHtml(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="${escapeHtml(ja4)}">${escapeHtml(ja4.substring(0,20))}…</a>`;
|
||||
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]" title="${escapeHtml(ja4)}">${escapeHtml(ja4.substring(0,22))}…</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">${escapeHtml(ja4)}</a>`;
|
||||
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]">${escapeHtml(ja4)}</a>`;
|
||||
}
|
||||
function fmtBotName(name) {
|
||||
if (!name) return '';
|
||||
@ -164,9 +255,23 @@
|
||||
}
|
||||
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'};
|
||||
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',cdn:'text-cyan-400 bg-cyan-500/10'};
|
||||
return `<span class="px-1.5 py-0.5 rounded text-xs ${colors[label]||'text-gray-400 bg-gray-500/10'}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
function fmtDuration(seconds) {
|
||||
if (!seconds || seconds < 0) return '—';
|
||||
if (seconds < 60) return Math.round(seconds) + 's';
|
||||
if (seconds < 3600) return Math.round(seconds/60) + 'min';
|
||||
return (seconds/3600).toFixed(1) + 'h';
|
||||
}
|
||||
function fmtAgo(dateStr) {
|
||||
if (!dateStr) return '—';
|
||||
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
|
||||
if (diff < 60) return 'à l\'instant';
|
||||
if (diff < 3600) return Math.round(diff/60) + ' min';
|
||||
if (diff < 86400) return Math.round(diff/3600) + 'h';
|
||||
return Math.round(diff/86400) + 'j';
|
||||
}
|
||||
|
||||
// ── ECharts helpers ──
|
||||
const EC_COLORS = ['#6366f1','#22c55e','#f97316','#ef4444','#3b82f6','#eab308','#ec4899','#14b8a6','#8b5cf6','#f43f5e'];
|
||||
@ -176,17 +281,39 @@
|
||||
function ecBase(overrides) {
|
||||
return Object.assign({
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: { color: EC_TEXT, fontFamily: 'Inter, system-ui, sans-serif' },
|
||||
animation: true, animationDuration: 600,
|
||||
textStyle: { color: EC_TEXT, fontFamily: 'Inter, system-ui, sans-serif', fontSize: 11 },
|
||||
animation: true, animationDuration: 400,
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
function ecTooltip(extra) {
|
||||
return Object.assign({
|
||||
backgroundColor: '#1f2937', borderColor: '#374151',
|
||||
textStyle: { color: '#e5e7eb', fontSize: 12 },
|
||||
textStyle: { color: '#e5e7eb', fontSize: 11 },
|
||||
confine: true,
|
||||
}, extra);
|
||||
}
|
||||
function ecGrid(extra) {
|
||||
return Object.assign({ left: 50, right: 20, top: 30, bottom: 30 }, extra);
|
||||
}
|
||||
|
||||
// ── Chart resize on window resize ──
|
||||
window.addEventListener('resize', () => {
|
||||
document.querySelectorAll('[_echarts_instance_]').forEach(el => {
|
||||
echarts.getInstanceByDom(el)?.resize();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Generic table builder ──
|
||||
function buildTable(tbody, rows, cols) {
|
||||
tbody.innerHTML = rows.map(r =>
|
||||
'<tr>' + cols.map(c => `<td>${c.fmt ? c.fmt(r[c.key], r) : escapeHtml(r[c.key]??'')}</td>`).join('') + '</tr>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ── Doc helper: generates a (?) button + panel ──
|
||||
function docHTML(title, body, source) {
|
||||
return `<span class="relative inline-block ml-1"><button onclick="docToggle(this)" class="doc-btn" aria-label="Aide">?</button><div class="doc-panel"><h4>${escapeHtml(title)}</h4>${body}<p class="doc-source">Source : ${escapeHtml(source)}</p></div></span>`;
|
||||
}
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user