Playwright testing revealed 3 critical bugs:
1. Tailwind CDN @apply with custom brand-* colors produces empty CSS
rules, breaking ALL design components (kpi-card, data-table, badges,
filter-btn, section-card, nav-item). Fix: replace all @apply
directives with equivalent raw CSS values.
2. Traffic API and IP detail API reference non-existent 'status' column
in http_logs table → HTTP 500 on /traffic and /ip/{ip}. Fix: remove
status from SELECT, sort whitelist, filters, and templates.
3. Nested <a> links (fmtJA4, fmtASN, fmtCountry, fmtBotName) inside
clickable <tr onclick> capture clicks, preventing row navigation to
/ip/ detail. Fix: add event.stopPropagation() to all formatter links.
Verified with Playwright: 10 pages × 0 JS errors, all tooltips hidden
by default, sidebar toggle works, keyboard shortcuts (Alt+1-9, Alt+B),
classification form saves to DB, campaign detail panel opens on click.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
373 lines
23 KiB
HTML
373 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr" class="dark">
|
|
<head>
|
|
<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://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
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'] },
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
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 cards ── */
|
|
.kpi-card { background: rgba(17,24,39,0.8); border-radius: 0.75rem; padding: 1rem; border: 1px solid #1f2937; backdrop-filter: blur(8px); }
|
|
/* ── Data table ── */
|
|
.data-table { width: 100%; font-size: 0.875rem; text-align: left; }
|
|
.data-table th { padding: 0.625rem 0.75rem; background: #111827; color: #9ca3af; font-weight: 500; border-bottom: 1px solid #1f2937; position: sticky; top: 0; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.data-table td { padding: 0.5rem 0.75rem; border-bottom: 1px solid rgba(31,41,55,0.5); color: #d1d5db; }
|
|
.data-table tbody tr:hover { background: rgba(31,41,55,0.4); }
|
|
.data-table tbody tr { cursor: pointer; transition: background-color 0.15s, color 0.15s; }
|
|
/* ── Badges ── */
|
|
.badge { display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
|
|
.badge-critical { background: rgba(239,68,68,0.2); color: #f87171; }
|
|
.badge-high { background: rgba(249,115,22,0.2); color: #fb923c; }
|
|
.badge-medium { background: rgba(234,179,8,0.2); color: #facc15; }
|
|
.badge-low { background: rgba(34,197,94,0.2); color: #4ade80; }
|
|
.badge-normal { background: rgba(107,114,128,0.2); color: #9ca3af; }
|
|
.badge-known { background: rgba(59,130,246,0.2); color: #60a5fa; }
|
|
/* ── Filter buttons ── */
|
|
.filter-btn { padding: 0.375rem 0.75rem; font-size: 0.75rem; border-radius: 0.5rem; border: 1px solid #374151; color: #9ca3af; transition: border-color 0.15s, color 0.15s; cursor: pointer; background: transparent; }
|
|
.filter-btn:hover { border-color: #6366f1; color: #6366f1; }
|
|
.filter-btn.active { border-color: #6366f1; background: rgba(99,102,241,0.2); color: #6366f1; }
|
|
/* ── Section card ── */
|
|
.section-card { background: rgba(17,24,39,0.8); border-radius: 0.75rem; border: 1px solid #1f2937; backdrop-filter: blur(8px); }
|
|
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.25rem; border-bottom: 1px solid #1f2937; }
|
|
.section-title { font-size: 0.875rem; font-weight: 600; color: #e5e7eb; display: flex; align-items: center; gap: 0.5rem; }
|
|
.section-body { padding: 1.25rem; }
|
|
/* ── Sidebar ── */
|
|
.nav-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.75rem; border-radius: 0.5rem; color: #9ca3af; transition: color 0.15s, background 0.15s; font-size: 0.875rem; cursor: pointer; text-decoration: none; }
|
|
.nav-item:hover { color: white; background: rgba(31,41,55,0.6); }
|
|
.nav-item.active { background: rgba(79,70,229,0.2); color: #818cf8; border-left: 2px solid #6366f1; }
|
|
.nav-group-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #4b5563; padding: 1rem 0.75rem 0.25rem; }
|
|
</style>
|
|
<style>
|
|
/* ── Raw CSS (no @apply) for reliable rendering ── */
|
|
.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; }
|
|
|
|
/* ── Infobulles (hover tooltips) ── */
|
|
.doc-btn {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 16px; height: 16px; border-radius: 50%; font-size: 10px;
|
|
color: #4b5563; cursor: help; transition: all 0.15s;
|
|
vertical-align: middle; margin-left: 4px; flex-shrink: 0;
|
|
}
|
|
.doc-btn:hover { color: #d1d5db; background: #374151; }
|
|
.doc-panel {
|
|
display: none; position: absolute; z-index: 60;
|
|
top: calc(100% + 10px); right: -8px; width: 300px;
|
|
padding: 12px 14px; background: #111827; border: 1px solid #374151;
|
|
border-radius: 10px; box-shadow: 0 16px 48px rgba(0,0,0,0.6);
|
|
font-size: 11px; line-height: 1.6; color: #d1d5db;
|
|
pointer-events: auto;
|
|
}
|
|
.doc-panel::before {
|
|
content: ''; position: absolute; top: -5px; right: 14px;
|
|
width: 10px; height: 10px; background: #111827;
|
|
border-left: 1px solid #374151; border-top: 1px solid #374151;
|
|
transform: rotate(45deg);
|
|
}
|
|
.doc-panel h4 { color: white; font-weight: 600; font-size: 12px; margin: 0 0 6px; }
|
|
.doc-panel p { margin: 0 0 5px; }
|
|
.doc-panel .doc-source { color: #6b7280; font-style: italic; margin-top: 6px; padding-top: 6px; border-top: 1px solid #1f2937; font-size: 10px; }
|
|
/* Hover: show on parent hover */
|
|
.relative:has(> .doc-btn):hover > .doc-panel,
|
|
.doc-panel.show {
|
|
display: block; animation: ttIn 0.12s ease-out;
|
|
}
|
|
/* Mobile: keep tap toggle via JS */
|
|
@keyframes ttIn { from { opacity:0; transform:translateY(-3px); } to { opacity:1; transform:translateY(0); } }
|
|
|
|
/* ── 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: 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 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="/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>
|
|
<a href="/campaigns" class="nav-item {% if active_page == 'campaigns' %}active{% endif %}" title="Campagnes de bots (clusters)">
|
|
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
|
<span class="nav-text">Campagnes</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" class="nav-text"></span>
|
|
</span>
|
|
</div>
|
|
</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/90 backdrop-blur-md border-b border-gray-800">
|
|
<div class="flex items-center h-12 px-4 lg:px-6">
|
|
<h1 class="text-sm lg:text-base font-semibold text-gray-100 truncate">{% block page_title %}{% endblock %}</h1>
|
|
<div class="flex-1"></div>
|
|
{% block header_actions %}{% endblock %}
|
|
</div>
|
|
</header>
|
|
<main class="px-3 py-4 lg:px-5 lg:py-5 xl:px-6">
|
|
{% 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';
|
|
}
|
|
// Auto-collapse on narrow screens
|
|
if (window.innerWidth < 1024) toggleSidebar();
|
|
|
|
// ── Keyboard shortcuts ──
|
|
document.addEventListener('keydown', e => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
|
|
const routes = {'1':'/', '2':'/detections', '3':'/scores', '4':'/campaigns',
|
|
'5':'/traffic', '6':'/network', '7':'/features', '8':'/models', '9':'/classify'};
|
|
if (e.altKey && routes[e.key]) { e.preventDefault(); window.location = routes[e.key]; }
|
|
if (e.key === 'b' && e.altKey) { e.preventDefault(); toggleSidebar(); }
|
|
});
|
|
|
|
// ── Clock ──
|
|
function updateClock() {
|
|
const el = document.getElementById('clock');
|
|
if (el) el.textContent = new Date().toLocaleString('fr-FR');
|
|
}
|
|
updateClock(); setInterval(updateClock, 1000);
|
|
|
|
// ── Doc tooltip (mobile tap fallback) ──
|
|
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');
|
|
// Auto-dismiss after 8s on mobile
|
|
if (panel.classList.contains('show')) {
|
|
clearTimeout(panel._timer);
|
|
panel._timer = setTimeout(() => panel.classList.remove('show'), 8000);
|
|
}
|
|
}
|
|
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','LEGITIMATE_BROWSER':'badge-low'
|
|
};
|
|
return `<span class="badge ${map[level]||'badge-normal'}">${escapeHtml(level||'')}</span>`;
|
|
}
|
|
function escapeHtml(s) {
|
|
const d = document.createElement('div');
|
|
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 font-mono text-xs">${escapeHtml(s)}</a>`;
|
|
}
|
|
function fmtScore(v) {
|
|
let n = parseFloat(v);
|
|
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} font-mono">${n.toFixed(4)}</span>`;
|
|
}
|
|
function fmtASN(org) {
|
|
if (!org) return '';
|
|
return `<a href="/network?asn_org=${encodeURIComponent(org)}" onclick="event.stopPropagation()" 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':'🇮🇳','SG':'🇸🇬','SE':'🇸🇪','FI':'🇫🇮','IE':'🇮🇪','CH':'🇨🇭'};
|
|
return `<a href="/detections?country_code=${encodeURIComponent(cc)}" onclick="event.stopPropagation()" class="hover:underline cursor-pointer">${flags[cc]||'🏳️'} ${escapeHtml(cc)}</a>`;
|
|
}
|
|
function fmtJA4(ja4) {
|
|
if (!ja4) return '';
|
|
return `<a href="/detections?ja4=${encodeURIComponent(ja4)}" onclick="event.stopPropagation()" 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)}" onclick="event.stopPropagation()" class="text-purple-400 hover:underline cursor-pointer font-mono text-[11px]">${escapeHtml(ja4)}</a>`;
|
|
}
|
|
function fmtBotName(name) {
|
|
if (!name) return '';
|
|
return `<a href="/detections?bot_name=${encodeURIComponent(name)}" onclick="event.stopPropagation()" class="text-cyan-400 hover:underline cursor-pointer">${escapeHtml(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',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'];
|
|
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', fontSize: 11 },
|
|
animation: true, animationDuration: 400,
|
|
}, overrides);
|
|
}
|
|
function ecTooltip(extra) {
|
|
return Object.assign({
|
|
backgroundColor: '#1f2937', borderColor: '#374151',
|
|
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 an ⓘ tooltip 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>
|
|
</html>
|